UICollectionView 自定义布局教程: Pinterest

来源:互联网 发布:还原网络设置会怎么样 编辑:程序博客网 时间:2024/05/16 06:36

原文:
UICollectionView Custom Layout Tutorial: Pinterest
作者:Paride Broggi
译者:kmyhy

更新说明:本教程由 Paride Broggi 升级到 Xcode 9,Swfit 4。原文作者是 Ernesto García。

UICollectionView 从 iOS 6 开始引入,是 iOS 开发者最爱使用的 UI 元素。最主要的一点是数据和展现层分离,允许用一个单独的对象来处理布局。而布局决定了视图的位置和外观属性。

你可能用过默认的流式布局——这是 UIKit 提供的一个布局类——它实现了基本的网格式布局,允许一定程度的定制化。但是,你也可以实现自定义布局,将视图随心所欲地摆放;因此 collection view 才会变得如此的灵活多变和强大。

在这篇 UICollectionView 自定义布局教程中,你将创建经常在 Pinterest app 中看到的布局。

在这个过程中,你将学多和许多自定义布局有关的只是,比如计算和缓存布局属性,如何处理动态计算大小的 cell 等。

注意:本文需要对 UICollectionView 有一定基础。如果你不熟悉它,可以阅读我们的视频教程系列:

  • UICollectionView Tutorial Part 1: Getting Started
  • UICollectionView Tutorial Part 2: Reusable Views and Cell Selection
  • Video Tutorial: Collection Views Part 0: Introduction

准备好了吗?那就开始吧!

开始

下载开始项目并用 Xcode 打开它。

Build & run,你会看到:

这个 app 显示了 RWDevCon 的相集。你可以浏览照片,看看会议中的出席者们有趣的一瞬间。

这个相集用一个 collection view 以标准流式布局进行布局。乍一看,好像没什么问题。但布局方式肯定需要改进。照片没有完全填充 cell, 同时,大段文本会被截断。

整个用户体验十分不好而且呆板,因为 cell 大小固定。改进的方式之一,就是创建自定义的布局,每个 cell 都能根据需要完美适配。

创建自定义 Collection View 布局

要创建令人满意的 collection view 的第一步,是创建一个自定义布局。

collection view 布局需要继承抽象类 UICollectionViewLayout。它们定义了 collection view 中每个 cell 的外观属性。而每个属性都是 UICollectionViewLayoutAttributes 对象,collection view 中的每个 cell 又包含了这些属性,比如 cell 的 frame 或者 transform。

在 Layouts 下面新建文件。选择 iOS\Source 下的 Cocoa Touch Class,名字叫做 PinterestLayout,继承 UICollectionViewLayout。语言选择 Swift,然后创建这个文件。

然后告诉 collection view 使用这个新的 layout。

打开 Main.storyboard,选中 Photo Stream View Controller Scene 下面的 Collection View:

接着,打开属性检查器。在 Layout 下拉列表选择 Custom,然后在 Class 下拉列表中选择 PinterestLayout:

好——来看看它变成什么样子。Build & run:

别紧张!这是一个好兆头,相信我。这说明 collection view 已经用上你的 layout 类了。cell 不显示的原因在于 PinterestLayout 类没有实现任何布局方法。

主要布局过程

来看一下 collection view 的布局过程,这是一个由 collection view 和 layout 对象进行协作的过程。当 collection view 需要布局信息时,它会按照特定顺序以方法调用的方式询问 layout 对象:

你的 layout 子类必须实现这些方法:

  • collectionViewContentSize: 这个方法返回 collection view 的 content 大小。你必须重写这个方法。同时返回整个 collection view 的 content 的大小——不仅仅包含可见的内容。collection view 用这个大小作为它的 scroll view 的 content 大小。
  • prepare(): 这个方法当布局操作即将发生时调用。你可以利用它进行计算 Collection view 大小和 cell 的位置的准备工作和计算动作。
  • layoutAttributesForElements(in:): 在这个方法里,你需要返回指定矩形范围内的 cell 的布局属性。以 UICollectionViewLayoutAttributes 数组的形式返回。
  • layoutAttributesForItem(at:): 这个方法提供 collection view 要用到的布局信息。你必须覆盖这个方法并返回指定 indexPath 位置的 cell 的布局属性。

好了,你已经知道你必须实现的东西了——但你要怎样才能算出这些属性呢?

计算布局属性

对于这个布局,你需要动态计算出每个 cell 的高,因为你无法提前获知图片的大小。你将声明一个协议在 PinterestLayout 需要它的时候提供这个信息。

然后回到代码中。打开 Open PinterestLayout.swift 在定义 PinterestLayout 类之前声明一个委托协议:

protocol PinterestLayoutDelegate: class {  func collectionView(_ collectionView:UICollectionView, heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat}

这段代码声明了 PinterestLayoutDelegate 协议,其中有一个获得照片高度的方法。你将在 PhotoStreamViewController 中来实现这个协议。

在实现那些布局方法之前,还有一件事情要做。你必须声明几个属性将在布局过程中用到。

添加下列 PinterestLayout:

// 1weak var delegate: PinterestLayoutDelegate!// 2fileprivate var numberOfColumns = 2fileprivate var cellPadding: CGFloat = 6// 3fileprivate var cache = [UICollectionViewLayoutAttributes]()// 4fileprivate var contentHeight: CGFloat = 0fileprivate var contentWidth: CGFloat {  guard let collectionView = collectionView else {    return 0  }  let insets = collectionView.contentInset  return collectionView.bounds.width - (insets.left + insets.right)}// 5override var collectionViewContentSize: CGSize {  return CGSize(width: contentWidth, height: contentHeight)}

这段代码定义了几个后面要用到的和布局信息相关的属性,分别是:

  1. 引用委托对象。
  2. 两个配置布局的属性:列数和 cell 的间距。
  3. 一个数组,用于缓存计算过的属性。当你调用 prepare() 时,你需要计算所有 cell 的属性并缓存到这里。当 collection view 请求布局属性时,你可以从缓存中查找属性,而不是每次都要重新计算它们。
  4. 这里声明了两个属性用于保存 content 大小。contentHeight 每当添加一张照片时都会增加,contentWidth 用 collection view 的宽度和 content 的 inset 进行计算。
  5. 重写 collectionViewContentSize 方法,返回 collection view 的 content 大小。

准备工作完成之后,就是计算 frame 了。要了解如何计算 frame,请看下图:

每个 cell 的 frame 根据它的列数(用 xOffset 表示)以及上一个同列的 cell 来计算(用 yOffset 表示)。

水平位置的计算,需要用到 cell 所属的这列的 x 起始坐标,加上 cell 的间距。垂直位置用这一列的上一个 cell 的起始位置,加上上一个 cell 的高度。cell 的高度则是用图片高度加上 content 边距。

在 prepare() 中进行这些工作,一开始的目标是计算出布局中所有 cell 的UICollectionViewLayoutAttributes。

在 PinterestLayout 中添加下列方法:

override func prepare() {  // 1  guard cache.isEmpty == true, let collectionView = collectionView else {    return  }  // 2  let columnWidth = contentWidth / CGFloat(numberOfColumns)  var xOffset = [CGFloat]()  for column in 0 ..< numberOfColumns {    xOffset.append(CGFloat(column) * columnWidth)  }  var column = 0  var yOffset = [CGFloat](repeating: 0, count: numberOfColumns)  // 3  for item in 0 ..< collectionView.numberOfItems(inSection: 0) {    let indexPath = IndexPath(item: item, section: 0)    // 4    let photoHeight = delegate.collectionView(collectionView, heightForPhotoAtIndexPath: indexPath)    let height = cellPadding * 2 + photoHeight    let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)    let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)    // 5    let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)    attributes.frame = insetFrame    cache.append(attributes)    // 6    contentHeight = max(contentHeight, frame.maxY)    yOffset[column] = yOffset[column] + height    column = column < (numberOfColumns - 1) ? (column + 1) : 0  }}

分别解释一下:

  1. 只有 collectoin view 不为空同时该 layout 属性未缓存时才计算这个 layout 属性。
  2. 声明 xOffset 数组,根据列宽用填充每列的 x 坐标。yOffset 数组保存每列的 y 坐标。开始时 yOffset 为 0,因为这是每列的第一个 cell 的位置。
  3. 这个循环遍历第一个 section 中的所有 cell,而这个布局也只会有一个 section。
  4. 现在开始计算 frame。宽度是先前计算的的 columnWidth,但是包含了两个 cell 之间的 Padding。询问委托对象照片的高度,根据这个高度和前面定义的 cellPadding 作为上下两端的留白,计算出 frame 的高度。然后将当前列的 offsets 的 x,y 算进去,算出 insetFrame 用于属性。
  5. 创建一个 UICollectionViewLayoutAttributes 对象,将 frame 设置为 insetFrame,将它缓存到 cache。
  6. 根据新计算好的 cell 的 frame 来更新 contentHeight。然后根据 frame 来更新当前列的 yOffset。最后,它修改列数 column,以便下一个 cell 会放到下一列中。

注意:因为 prepare() 会在 colledtion view 布局尚不可用时调用,在许多时候你不得不重新计算这里的属性。例如,UICollectionView 的矩形可能会变化——例如屏幕旋转时——或者 cell 被添加或者删除时。这些情况不在本文考虑的范围之内,但你需要知道这些实现的区别。

现在你需要重写 layoutAttributesForElements(in:),这个方法会在 prepare() 方法之后 cell 会在某个矩形范围内可见时调用。

在 PinterestLayout 最后添加:

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {  var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()  // Loop through the cache and look for items in the rect  for attributes in cache {    if attributes.frame.intersects(rect) {      visibleLayoutAttributes.append(attributes)    }  }  return visibleLayoutAttributes}

这里对缓存中的属性进行递归,检查它们的 frame 是否和 rect 相交,是的话返回给 collection view。将和 rect 相交的属性添加到 layoutAttributes 中,最后将 layoutAttributes 返回给 collection view。

最后还有一个方法需要实现 layoutAttributesForItem(at:):

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {  return cache[indexPath.item]}

这里,直接从缓存中查找并返回和 indexPath 对应的布局属性。

在你能真真看到你的布局之前,你还需要实现布局委托。

PinterestLayout 依赖它来提供照片和备注的高度,用于计算每个属性的 frame 高度。

打开 PhotoStreamViewController.swift 在文件最后添加一个扩展,声明采用 PinterestLayoutDelegate 协议:

extension PhotoStreamViewController: PinterestLayoutDelegate {  func collectionView(_ collectionView: UICollectionView,                      heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat {    return photos[indexPath.item].image.size.height  }}

这个方法中你将照片的真实高度用于布局。

然后,在 viewDidLoad() 方法中调用 super 一句前添加:

if let layout = collectionView?.collectionViewLayout as? PinterestLayout {  layout.delegate = self}

这里将 PhotoStreamViewController 设置为布局对象的 delegate。

现在来看一下效果!Build & run。你会看到 cell 都待在了正确的地方,而且根据照片的大小来改变大小:

你编写了一个完全自定义的 collection view 布局——太好了!

接下来做什么?

你可以从这里下载本教程最后包含完整代码的项目。

通过比你预先想过的要少的工作量,你编写了一个非常 Pinterest 样的自定义布局!

如果你想学习更自定义布局,请参考下列资源:

  • Creating Custom Layouts section of the Collection View Programming Guide for iOS,它包含了大量内容。
  • 我们的 Custom Collection View Layout Video Tutorial Series,你会学到如何受一些时髦的 app 激发创意编写令人赞叹的自定义布局,以及深入一些高级主题,比如创建交互式布局,管理滚动行为。

有任何问题和评论,请在论坛留言。

原创粉丝点击