UIKit框架(二十六) —— UICollectionView的自定义布局 (一)

版本记录

版本号 时间
V1.0 2019.09.16 星期一

前言

iOS中有关视图控件用户能看到的都在UIKit框架里面,用户交互也是通过UIKit进行的。感兴趣的参考上面几篇文章。
1. UIKit框架(一) —— UIKit动力学和移动效果(一)
2. UIKit框架(二) —— UIKit动力学和移动效果(二)
3. UIKit框架(三) —— UICollectionViewCell的扩张效果的实现(一)
4. UIKit框架(四) —— UICollectionViewCell的扩张效果的实现(二)
5. UIKit框架(五) —— 自定义控件:可重复使用的滑块(一)
6. UIKit框架(六) —— 自定义控件:可重复使用的滑块(二)
7. UIKit框架(七) —— 动态尺寸UITableViewCell的实现(一)
8. UIKit框架(八) —— 动态尺寸UITableViewCell的实现(二)
9. UIKit框架(九) —— UICollectionView的数据异步预加载(一)
10. UIKit框架(十) —— UICollectionView的数据异步预加载(二)
11. UIKit框架(十一) —— UICollectionView的重用、选择和重排序(一)
12. UIKit框架(十二) —— UICollectionView的重用、选择和重排序(二)
13. UIKit框架(十三) —— 如何创建自己的侧滑式面板导航(一)
14. UIKit框架(十四) —— 如何创建自己的侧滑式面板导航(二)
15. UIKit框架(十五) —— 基于自定义UICollectionViewLayout布局的简单示例(一)
16. UIKit框架(十六) —— 基于自定义UICollectionViewLayout布局的简单示例(二)
17. UIKit框架(十七) —— 基于自定义UICollectionViewLayout布局的简单示例(三)
18. UIKit框架(十八) —— 基于CALayer属性的一种3D边栏动画的实现(一)
19. UIKit框架(十九) —— 基于CALayer属性的一种3D边栏动画的实现(二)
20. UIKit框架(二十) —— 基于UILabel跑马灯类似效果的实现(一)
21. UIKit框架(二十一) —— UIStackView的使用(一)
22. UIKit框架(二十二) —— 基于UIPresentationController的自定义viewController的转场和展示(一)
23. UIKit框架(二十三) —— 基于UIPresentationController的自定义viewController的转场和展示(二)
24. UIKit框架(二十四) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (一)
25. UIKit框架(二十五) —— 基于UICollectionViews和Drag-Drop在两个APP间的使用示例 (二)

开始

首先看下主要内容

构建一个受Pinterest应用程序启发的UICollectionView自定义布局,并学习如何缓存属性并动态调整单元格大小。

下面看下写作环境

Swift 5, iOS 13, Xcode 11

iOS 6中引入的UICollectionView已成为iOS开发人员中最受欢迎的UI元素之一。它如此吸引人的是数据和表示层之间的分离,这取决于处理布局的单独对象。然后,布局负责确定视图的放置和视觉属性。

您可能使用了默认的流布局,即UIKit提供的布局类。这是一个带有一些自定义的基本网格布局。

但您也可以实现自己的自定义布局,以任何方式排列视图。这使得集合视图具有灵活性和强大功能。

在这个UICollectionView自定义布局教程中,您将创建一个受流行的Pinterest应用程序启发的布局。

在此过程中,您将学习:

  • 关于自定义布局。
  • 如何计算和缓存布局属性。
  • 如何处理动态大小的单元格。

在Xcode中打开下载好的项目并启动项目。

构建并运行项目。 你会看到以下内容:

该应用程序提供了RWDevCon的照片库。 您可以浏览照片,看看与会者在会议期间有多么有趣。

该库使用具有标准流布局的集合视图。 乍一看,它看起来还不错。 但你当然可以改进布局设计。

照片并未完全填满内容区域。 长字幕被截断。 用户体验是无聊和静态的,因为所有单元格大小相同。

您可以使用自定义布局改进设计,其中每个单元格可以自由地满足其需求。


Creating Custom Collection View Layouts

您将首先为图库创建自定义布局类,从而创建令人惊叹的集合视图(collection view)

集合视图布局是抽象类UICollectionViewLayout的子类。 它们定义集合视图中每个项目的可视属性。

各个属性是UICollectionViewLayoutAttributes的实例。 它们包含集合视图中每个项目的属性,例如项目的frametransform

Layouts组中创建一个新文件。 从iOS ▸ Source列表中选择Cocoa Touch Class。 将其命名为PinterestLayout并使其成为UICollectionViewLayout的子类。

接下来,配置集合视图以使用新布局。 打开Main.storyboard。 在Photo Stream View Controller Scene中选择Collection View,如下所示:

接下来,打开Attributes inspector。 在Layout下拉列表中选择Custom。 然后在Class下拉列表中选择PinterestLayout

好的 - 是时候看一下它的样子了。 构建并运行您的应用:

别恐慌! 信不信由你,这是一个好兆头。

这意味着集合视图正在使用您的自定义布局类。 单元格未显示,因为PinterestLayout尚未实现布局过程中涉及的任何方法。


Core Layout Process

想想集合视图布局过程。 它是集合视图和布局对象之间的协作。 当集合视图需要一些布局信息时,它会要求您的布局对象通过按特定顺序调用某些方法来提供它:

您的布局子类必须实现以下方法:

  • collectionViewContentSize:此方法返回集合视图内容的宽度和高度。您必须实现它以返回整个集合视图内容的高度和宽度,而不仅仅是可见内容。集合视图在内部使用此信息来配置其滚动视图的内容大小。
  • prepare():每当布局操作即将发生时,UIKit都会调用此方法。这是您准备和执行确定集合视图大小和项目位置所需的任何计算的机会。
  • layoutAttributesForElements(in :):在此方法中,返回给定矩形内所有项的布局属性。您将属性作为UICollectionViewLayoutAttributes数组返回到集合视图。
  • layoutAttributesForItem(at :):此方法向集合视图提供按需布局信息。您需要覆盖它并在请求的indexPath处返回该项的布局属性。

好的,所以你知道你需要实现什么。但是你如何计算这些属性呢?


Calculating Layout Attributes

对于此布局,您需要动态计算每个项目的高度,因为您事先不知道照片的高度。 您将声明一个协议,当PinterestLayout需要它时,它将提供此信息。

现在,回到代码。 打开PinterestLayout.swift。 在PinterestLayout类之前添加以下委托协议声明:

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

此代码声明了PinterestLayoutDelegate协议。 它有一种方法来请求照片的高度。 您很快就会在PhotoStreamViewController中实现此协议。

在实现布局方法之前还有一件事要做。 您需要声明一些有助于布局过程的属性。

将以下内容添加到PinterestLayout

// 1
weak var delegate: PinterestLayoutDelegate?

// 2
private let numberOfColumns = 2
private let cellPadding: CGFloat = 6

// 3
private var cache: [UICollectionViewLayoutAttributes] = []

// 4
private var contentHeight: CGFloat = 0

private var contentWidth: CGFloat {
  guard let collectionView = collectionView else {
    return 0
  }
  let insets = collectionView.contentInset
  return collectionView.bounds.width - (insets.left + insets.right)
}

// 5
override var collectionViewContentSize: CGSize {
  return CGSize(width: contentWidth, height: contentHeight)
}

此代码定义了稍后您需要提供布局信息的一些属性。这是一步一步解释的:

  • 1) 这保留了对代理的引用。
  • 2) 这些是用于配置布局的两个属性:列数和单元格填充。
  • 3) 这是一个用于缓存计算属性的数组。当您调用prepare()时,您将计算所有项的属性并将它们添加到缓存中。当集合视图稍后请求布局属性时,您可以有效地查询缓存,而不是每次都重新计算它们。
  • 4) 这声明了两个属性来存储内容大小。在添加照片时增加contentHeight,并根据集合视图宽度及其内容插入计算contentWidth
  • 5) collectionViewContentSize返回集合视图内容的大小。您可以使用前面步骤中的contentWidthcontentHeight来计算大小。

您已准备好计算集合视图项的属性。现在,它将由frame组成。要了解您将如何执行此操作,请查看下图:

您将根据每个项目的列以及同一列中上一个项目的位置来计算每个项目的frame。 您可以通过跟踪framexOffset和上一个项目的位置yOffset来完成此操作。

您将首先使用项目所属列的起始X坐标来计算水平位置,然后添加单元格填充。 垂直位置是该列中前一项的起始位置,加上该前一项的高度。 整体项目高度是图像高度和内容填充的总和。

你将在prepare()中做到这一点。 您的主要目标是为布局中的每个项计算UICollectionViewLayoutAttributes的实例。

将以下方法添加到PinterestLayout

override func prepare() {
  // 1
  guard 
    cache.isEmpty, 
    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] = .init(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) ?? 180
    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) 如果缓存cache为空且集合视图存在,则只计算布局属性。
  • 2) 根据列宽度为每列声明并填充xOffset数组。 yOffset数组跟踪每列的y位置。您将yOffset中的每个值初始化为0,因为这是每列中第一个项目的偏移量。
  • 3) 遍历第一section中的所有项目,因为此特定布局只有一个部分。
  • 4) 执行frame计算。 width是先前计算的cellWidth,其中删除了单元格之间的填充。向代理请求照片的高度,然后根据此高度和顶部和底部的预定义cellPadding计算frame高度。如果没有设置代理,请使用默认单元格高度。然后,将其与当前列的xy偏移量组合,以创建属性使用的insetFrame
  • 5) 创建UICollectionViewLayoutAttributes的实例,使用insetFrame设置其frame并将属性附加到cache
  • 6) 展开contentHeight以考虑新计算项目的frame。然后,根据frame推进当前列的yOffset。最后,推进column,以便下一个项目放在下一列中。

注意:由于只要集合视图的布局变得无效就会调用prepare(),因此在典型实现中有许多情况需要在此处重新计算属性。例如,当方向更改时,UICollectionView的边界可能会更改。如果从集合中添加或删除项目,它们也可能会更改。

现在您需要重写layoutAttributesForElements(in :)。集合视图在prepare()之后调用它以确定哪些项在给定矩形中可见。

将以下代码添加到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
}

在这里,您遍历cache中的属性并检查它们的frame是否与集合视图提供的rect相交。

使用与该rect相交的framevisibleLayoutAttributes添加任何属性,最终返回到集合视图。

您必须实现的最后一个方法是layoutAttributesForItem(at :)

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

在这里,您从cache中检索并返回与请求的indexPath对应的布局属性。


Connecting with UIViewController

在您可以看到正在运行的布局之前,您需要实现布局代理。 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设置为您的布局的代理。

是时候再看一下了! 构建并运行您的应用程序。 您将看到根据照片的高度正确定位和调整单元格:

通过比您想象的更少的工作,您已经创建了自己的Pinterest式自定义布局!

如果您想了解有关自定义布局的更多信息,请考虑以下资源:

后记

本篇主要讲述了UICollectionView的自定义布局,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容