最近把 Ray 家的 Custom Collection View Layouts 系列视频刷了一遍,做一下记录
在开始前,先来看一下复习下基本知识:
UICollectionView 向 UICollectionViewLayout 询问布局,而当询问过程发生时,layout 对象会创建 UICollectionViewLayoutAttributes 实例。一个 UICollectionViewLayoutAttributes 对象管理着一个对应的 item layout 相关信息(一对一关系)
来看一下第一部分的目标,我们要实现一个类似 Pinterest 的东西,有如下特性:
要实现上图的目标,我们先来解决布局的问题,为此要定义一个 UICollectionViewLayout 的子类,原因如下:
自定义一个 UICollectionViewLayout 的子类 PinterestLayout ,一般在子类中要重写三个方法:
override func collectionViewContentSize() -> CGSize
返回当前所以内容的 contentSize,不仅仅是当前可见的
override func collectionViewContentSize() -> CGSize { return CGSize(width: width, height: contentHeight) }
2. `override func prepareLayout()` 计算每一个 item 的 size,然后为每一个 item 创建对应的 attributes,并设置 frame,然后放到缓存中去 ```swift override func prepareLayout() { if cache.isEmpty { // 每个 item 的宽度是固定的 let columnWidth = width / CGFloat(numberOfColumns) var xOffsets = [CGFloat]() for column in 0..<numberOfColumns { xOffsets.append(CGFloat(column) * columnWidth) } var yOffsets = [CGFloat](count: numberOfColumns, repeatedValue: 0) var column = 0 for item in 0..<collectionView!.numberOfItemsInSection(0) { let indexPath = NSIndexPath(forItem: item, inSection: 0) let height = delegate.collectionView(collectionView!, heightForItemAtIndexPath: indexPath) let frame = CGRect(x: xOffsets[column], y: yOffsets[column], width: columnWidth, height: height) let attributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath) attributes.frame = frame cache.append(attributes) contentHeight = max(contentHeight, CGRectGetMaxY(frame)) yOffsets[column] = yOffsets[column] + height column = column >= (numberOfColumns - 1) ? 0 : ++column } } } ``` 3. `override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]?` 返回 rect 内所有 cells 和 views 的 **layout attributes** ```swift override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? { var layoutAttributes = [UICollectionViewLayoutAttributes]() for attributes in cache { if CGRectIntersectsRect(attributes.frame, rect) { layoutAttributes.append(attributes) } } return layoutAttributes } ``` 在上面的 `prepareLayout()` 中我们向 delegate 请求了 item 的高度: ```swift let height = delegate.collectionView(collectionView!, heightForItemAtIndexPath: indexPath)
这个 delegate 我们定义如下:
protocol PinterestLayoutDelegate { func collectionView(collectionView: UICollectionView, heightForItemAtIndexPath indexPath: NSIndexPath) -> CGFloat }
我们让 UICollectionViewController 对象来实现
extension PhotoStreamViewController: PinterestLayoutDelegate { func collectionView(collectionView: UICollectionView, heightForItemAtIndexPath indexPath: NSIndexPath) -> CGFloat { let random = arc4random_uniform(4) + 1 return CGFloat(random * 100) } }
我们观察 Pinterest 每个 cell 都由一个 image 和一个 annotation 组成,因此我们需要知道 image height 和 annotation height,我们可以重新定义 delegate 方法来返回对应的 height。除此之外,我们创建自己的 Layout Attributes ,为其添加 photoHeight(因为每个 item 的 height 都是不同的),最后让 PinterestLayout 和 cell 应用我们设置的 Layout Attributes
protocol PinterestLayoutDelegate { func collectionView(collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat func collectionView(collectionView: UICollectionView, heightForAnnotationAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat }
紧接着实现 delegate,我们给每个 annotation 返回了一个固定的 height:
extension PhotoStreamViewController: PinterestLayoutDelegate { func collectionView(collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat { let random = arc4random_uniform(4) + 1 return CGFloat(random * 100) } func collectionView(collectionView: UICollectionView, heightForAnnotationAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat { return 60 } }
最后修改 prepareLayout 中调用到 delegate 方法的地方,此时的 height = photoHeight + annotationHeight
按照文档说明,我们创建 UICollectionViewLayoutAttributes
的子类,要覆盖以下两个方法:
func copyWithZone(_ zone: NSZone) -> AnyObject
collection view 会拷贝 layout attribute 对象,实现此方法确保自定义的属性被拷贝
func isEqual(_ anObject: AnyObject?) -> Bool
在 iOS 7 之后,只有 attributes 改变之后,collection view 才会应用,如何判断改变了呢,就要用到此方法,先实现自定义属性的判断,再调用 super
具体实现如下:
class PinterestLayoutAttributes: UICollectionViewLayoutAttributes { var photoHeight: CGFloat = 0 override func copyWithZone(zone: NSZone) -> AnyObject { let copy = super.copyWithZone(zone) as! PinterestLayoutAttributes copy.photoHeight = photoHeight return copy } override func isEqual(object: AnyObject?) -> Bool { if let attributes = object as? PinterestLayoutAttributes { if attributes.photoHeight == photoHeight { return super.isEqual(object) } } return false } }
在 PinterestLayout 类中重写 layoutAttributesClass
方法
override class func layoutAttributesClass() -> AnyClass { return PinterestLayoutAttributes.self }
然后将 prepareLayout 中所有的 UICollectionViewLayoutAttributes 修改为 PinterestLayoutAttributes
为了应用我们自定义的 attributes,我们需要 UICollectionReusableView 对象实现 applyLayoutAttributes:
方法
override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes!) { super.applyLayoutAttributes(layoutAttributes) let attributes = layoutAttributes as! PinterestLayoutAttributes imageViewHeightLayoutConstraint.constant = attributes.photoHeight }
最后我们来让照片等比例地缩放到 cell 的 imageView 中
为了实现这一目标,我们可以很方便地使用 AVFoundation framework 提供的方法:
func AVMakeRectWithAspectRatioInsideRect(_ aspectRatio: CGSize, _ boundingRect: CGRect) -> CGRect
使用该方法用在 PinterestLayoutDelegate 的实现中,返回合适的 photoHeight:
extension PhotoStreamViewController: PinterestLayoutDelegate { func collectionView(collectionView: UICollectionView, heightForPhotoAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat { let photo = photos[indexPath.item] let boundingRect = CGRect(x: 0, y: 0, width: width, height: CGFloat(MAXFLOAT)) let rect = AVMakeRectWithAspectRatioInsideRect(photo.image.size, boundingRect) return rect.height } func collectionView(collectionView: UICollectionView, heightForAnnotationAtIndexPath indexPath: NSIndexPath, withWidth width: CGFloat) -> CGFloat { return 60 } }