如何在 UICollectionView 单元格中获得 1 像素宽度的边框? (提供代码)

How to get 1 pixel width borders in UICollectionView cells? (code provided)

如何在 UICollectionView 中获得完美的一 (1) 个带边框线的像素(例如制作月历)。问题是单元格相遇的地方,它们的边界相遇,所以它实际上是 2 个像素(而不是 1 个像素)。

因此这是一个与 CollectionView 布局相关的 issue/question。由于每个单元格放置它自己的 1 像素边框的方式会增加,当所有单元格都以零间距相遇时,整体看起来像 2 像素边框(除了 collectionView 的外边缘为 1 像素)

求approach/code用什么来解决这个问题。

这是我使用的自定义 UICollectionViewCell class,我使用 drawrect 在此处的单元格上设置了边框。

import UIKit

class GCCalendarCell: UICollectionViewCell {

    @IBOutlet weak var title : UITextField!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    override func drawRect(rect: CGRect) {
        self.layer.borderWidth = 1
        self.layer.borderColor = UIColor.redColor().CGColor
    }

}

我通常将边框宽度除以屏幕比例:

width = 1.0f / [UIScreen mainScreen].scale

使用 nativeScale!

CGFloat width = 1.0f / [UIScreen mainScreen].nativeScale;

您可以采取一些策略来解决这个问题。哪个最好取决于您布局的一些细节。

选项 1:半边框

在每个项目周围绘制宽度为所需宽度 1/2 的边框。这已被建议。它为项目之间的边框实现了正确的宽度,但集合边缘周围的边框也将是所需宽度的一半。要解决此问题,您可以在整个集合或部分周围绘制边框。

如果您的月份布局只呈现给定月份的天数,这意味着每个月都有一个独特的非矩形形状,修复边缘边界比它的价值更复杂。

选项 2:重叠单元格

在每个项目周围绘制完整的边框,但调整框架,使它们重叠边框宽度的一半,隐藏冗余。

这是最简单的策略,效果很好,但请注意以下几点:

  • 请注意布局的外边缘也略有变化。必要时对此进行补偿。
  • 边框会重叠,所以不能使用半透明边框。
  • 如果不是所有的边框都是相同的颜色,一些边缘将会丢失。如果只有一个具有不同边框的单元格,您可以调整项目的 z-index 以将其置于其他单元格之上。

选项 3:手动绘图

您可以在容器周围添加一个边框,然后在超级视图中手动绘制边框,而不是在单元格上使用边框。这可以通过覆盖 drawRect(_:) 并使用 UIBezierPaths 在单元格的间隔处绘制水平和垂直线来实现。这是最手动的选项。它为您提供更多控制权,但工作量更大。

选项 4:在每个单元格中仅绘制一些边框

与其指定图层的边框宽度和边框颜色(在整个视图周围绘制边框),不如有选择地绘制单元格的边缘。例如:每个单元格只能绘制它的底部和右边缘,除非它的上方或右侧没有单元格。

如果您的网格不规则,这可能会再次导致并发症。它还要求每个单元格了解它在网格中的上下文,这需要更多的结构。如果你走这条路,我建议子类化 UICollectionViewLayoutAttributes 并添加关于应该绘制哪些边的附加信息。可以通过在 drawRect(_:) 中为每条边创建 UIBezierPath 来再次绘制边。

关于非常彻底的公认答案,我认为这些方法中的任何一个都不是很干净。为了使它在所有情况下看起来都不错,您将要绘制网格 "manually" 但在 drawRect 中这样做不是一个好主意 a) 因为这将绘制 under 你的单元格和 b) drawRect 每秒绘制屏幕 60 次,因此你会遇到大型滚动视图的性能问题。

更好的 IMO 是使用 CALayers。使用 CALayer,您可以将网格线拖放到视图上一次,然后它会保留在 GPU 上,因此即使是巨大的网格也可以以 60fps 的速度绘制。我还发现实施起来更容易、更灵活。这是一个工作示例:

class GridLineCollectionView: UICollectionView {

    private var gridLineContainer: CALayer!
    private var verticalLines = [CALayer]()
    private var horizontalLiines = [CALayer]()

    private var cols: Int {
        return Int(self.contentSize.width / self.itemSize.width)
    }
    private var rows: Int {
        return Int(self.contentSize.height / self.itemSize.height)
    }
    private var colWidth: CGFloat {
        return self.itemSize.width + self.sectionInsets.left + self.sectionInsets.right
    }
    private var rowHeight: CGFloat {
        return self.itemSize.height + self.sectionInsets.top + self.sectionInsets.bottom
    }
    private var itemSize: CGSize {
        return (self.collectionViewLayout as! UICollectionViewFlowLayout).itemSize
    }
    private var sectionInsets: UIEdgeInsets {
        return (self.collectionViewLayout as! UICollectionViewFlowLayout).sectionInset
    }


    override func layoutSubviews() {
        super.layoutSubviews()

        if self.gridLineContainer == nil {
            self.gridLineContainer = CALayer()
            self.gridLineContainer.frame = self.bounds
            self.layer.addSublayer(self.gridLineContainer)
        }

        self.addNecessaryGridLayers()
        self.updateVerticalLines()
        self.updateHorizontalLines()
    }

    private func addNecessaryGridLayers() {
        while self.verticalLines.count < cols - 1 {
            let newLayer = CALayer()
            newLayer.backgroundColor = UIColor.darkGray.cgColor
            self.verticalLines.append(newLayer)
            self.gridLineContainer.addSublayer(newLayer)
        }
        while self.horizontalLiines.count < rows + 1 {
            let newLayer = CALayer()
            newLayer.backgroundColor = UIColor.darkGray.cgColor
            self.horizontalLiines.append(newLayer)
            self.gridLineContainer.addSublayer(newLayer)
        }
    }

    private func updateVerticalLines() {
        for (idx,layer) in self.verticalLines.enumerated() {
            let x = CGFloat(idx + 1) * self.colWidth
            layer.frame = CGRect(x: x, y: 0, width: 1, height: CGFloat(self.rows) * self.rowHeight)
        }
    }

    private func updateHorizontalLines() {
        for (idx,layer) in self.horizontalLiines.enumerated() {
            let y = CGFloat(idx) * self.rowHeight
            layer.frame = CGRect(x: 0, y: y, width: CGFloat(self.cols) * self.colWidth, height: 1)
        }
    }
}

这里的主要挑战是由于屏幕 WIDTH 的限制,很难得出像素完美的 border/cell 测量值。即,几乎总是有无法分割的剩余像素。例如,如果你想要一个内边框为 1px 的 3 列网格,并且屏幕宽度为 375px,这意味着每列宽度必须为 124.333 px,这是不可能的,因此如果你向下舍入并使用 124px 作为宽度,你会剩下 1 px。 (额外的微小像素对设计的影响有多大,是不是很疯狂?)

一个选项是选择一个给你完美数字的边框宽度,但这并不理想,因为你不应该让机会支配你的设计。此外,它可能不会成为面向未来的解决方案。

我调查了这方面做得最好的应用程序 Instagram,正如预期的那样,它们的边框看起来像素完美。我检查了每一列的宽度,结果发现最后一列的宽度不一样,这证实了我的怀疑。对于人眼来说,不可能通过一个像素来判断网格列是更窄还是更宽,但是当边框宽度不同时,它 DEFINITELY 是可见的。所以这种方法似乎是最好的

起初我尝试使用单个可重复使用的单元格视图并根据是否是最后一列更新宽度。这种方法有点奏效,但单元格看起来有点问题,因为调整单元格的大小并不理想。所以我刚刚创建了一个额外的可重复使用的单元格,该单元格将放在最后一列以避免调整大小。

首先,设置集合的宽度和间距并为集合视图单元格注册 2 个可重复使用的标识符

let borderWidth : CGFloat = 1
let columnsCount : Int = 3
let cellWidth = (UIScreen.main.bounds.width - borderWidth*CGFloat(columnsCount-1))/CGFloat(columnsCount)

let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = CGSize(width: cellWidth, height: cellWidth)
layout.minimumLineSpacing = borderWidth
layout.minimumInteritemSpacing = borderWidth

collectionView = UICollectionView(frame: self.frame, collectionViewLayout: layout)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: lastReuseIdentifier)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)

然后创建一个辅助方法来查看某个索引路径处的单元格是否在最后一列中

func isLastColumn(indexPath: IndexPath) -> Bool {
    return (indexPath.row % columnsCount) == columnsCount-1
}

更新集合视图的 cellForItemAt 委托方法,以便使用最后一列单元格

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let identifier = isLastColumn(indexPath: indexPath) ? lastReuseIdentifier : reuseIdentifier
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath)
    return cell
}

最后,以编程方式更新单元格的大小,因此最后一列单元格将接管所有剩余的可用空间 space

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let width = isLastColumn(indexPath: indexPath) ? UIScreen.main.bounds.width-(cellWidth+borderWidth)*CGFloat(columnsCount -1) : cellWidth
    return CGSize(width: width, height: cellWidth)
}

最终结果应该是这样的(附赠食物图片):

我通过继承 UICollectionViewFlowLayout 并利用 layoutAttributesForElementsInRect 方法解决了这个问题。这样您就可以设置自定义框架属性并允许重叠。这是最基本的例子:

@interface EqualBorderCellFlowLayout : UICollectionViewFlowLayout <UICollectionViewDelegateFlowLayout>

/*! @brief Border width. */
@property (strong, nonatomic) NSNumber *borderWidth;

@end
#import "EqualBorderCellFlowLayout.h"

@interface EqualBorderCellFlowLayout() { }
@end

@implementation EqualBorderCellFlowLayout

- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    
    NSArray *attributes = [super layoutAttributesForElementsInRect:rect];
    
    for (UICollectionViewLayoutAttributes *attribute in attributes) {
        
        if (self.borderWidth) {
            
            CGFloat origin_x = attribute.frame.origin.x;
            CGFloat origin_y = attribute.frame.origin.y;
            CGFloat width = attribute.frame.size.width;
            CGFloat height = attribute.frame.size.height;
            
            if (attribute.frame.origin.x == 0) {
                width = width + self.borderWidth.floatValue;
            }
            
            height = height + self.borderWidth.floatValue;
            attribute.frame = CGRectMake(origin_x, origin_y, width, height);
            
        }
        
    }
    
    return attributes;
    
}

- (CGFloat)minimumLineSpacing {
    return 0;
}

- (CGFloat)minimumInteritemSpacing {
    return 0;
}

- (UIEdgeInsets)sectionInset {
    return UIEdgeInsetsMake(0, 0, 0, 0);
}

@end