自定义视差 UICollectionViewLayout 教程

原文:Custom UICollectionViewLayout Tutorial With Parallax
作者:Paride Broggi
译者:kmyhy

注意: 本教程使用 Xcode 9.0 和 Swift 4。

UICollectionView 从 iOS 6 开始出现,在 iOS 10 中得到了改进,它是 iOS app 中用于自定义并以动画方式呈现数据集合的首选。

和 UICollectionView 关系紧密的一个玩意就是 UICollectionViewLayout。它控制了 collection view 中所有元素比如 cell 、supplementary view 和 decoration view 的属性.

UIKit 提供了一个 UICollectionViewLayout 的默认实现,即 UICollectionViewFlowLayout。这个类允许你只需要经过几个基本的定制就可以创建网格式的布局。

本教程教你如何子类化和定制化 UICollectionViewLayout 类,以及如何添加自定义的 supplementary view 、弹性、粘性和视差效果到 collection view。

注意:本教程需要中级 Swift 4.0 技能,高级 UICollectionView 和仿射变换技能,深刻理解collectionview 的核心布局流程。

如果你不了解以上主题,请阅读苹果官方文档…

…或者,阅读本站的优秀教程!

  • UICollectionView 教程: 开始
  • Tutorial: Reusable Views and Cell Selection
  • UICollectionView Custom Layout Tutorial: Pinterest
  • Video Tutorial: Collection Views

通过本教程的学习,你将实现类似如下的 Collection View:

准备好一举拿下“丛林杯”了吗?让我们开始吧!

开始

下载开始项目,用 Xcode 打开它。Build & run。

你会看到几只酷酷的猫头鹰显示在一个带 section 头和尾的标准 Collection View 里:

自定义视差 UICollectionViewLayout 教程_第1张图片

app 展示了参加 2017 丛林杯足球赛的“猫头鹰队”的队员们。section 头显示的是队员的职责(角色),section 尾显示的是它们竞争力。

让我们来看一眼开始项目:

在 JungleCupCollectionViewController.swift 中,你会发现它实现了 UICollectionViewController 子类以及 UICollectionDataSource 协议。它实现了所有的 required 方法和 optional 方法来添加 supplementary view。

JungleCupCollectionViewController 还实现了 MenuViewDelegate 协议。这个协议允许切换 collection view 的数据源。

在 Reusable Views 文件夹中,放置单元格所需的 UICollectionViewCell 以及 section 头和尾所需的 UICollectionReusableView。它们都和 Main.storyboard 中的对应视图进行了绑定。

此外,CustomLayout 还用到了自定义 supplementary view。HeaderView 和 MenuView 都是 UICollectionReusableView 子类。它们都链入到各自的 .xib 文件。

MockDataManager.swift 保存了所有参赛队伍的数据结构。为了简单起见,Xcode 项目中包含了所有用得到的 assets。

布局设置

Custom Layout 文件夹需要特别注意,因为它包含了两个重要文件:

  • CustomLayoutSettings.swift
  • CustomLayoutAttributes.swift

CustomLayoutSettings.swift 实现了一个包含所有布局设置的结构。第一组设置和 Collection view 的元素大小相关。第二组则用于定义布局行为,第三组用于创建布局间距。

布局属性

CustomLayoutAttributes.swift 实现了一个 UICollectionViewLayoutAttributes 子类: CustomLayoutAttributes。这个类存储了 collection view 在显示某个元素之前需要配置的所有信息。

它继承了父类的默认属性比如 frame、transform、transform3D、alpha 和 zIndex。

另外它还增加了一些自定义属性:

var parallax: CGAffineTransform = .identity
var initialOrigin: CGPoint = .zero
var headerOverlayAlpha = CGFloat(0)

这 3 个属性会在后面实现弹性和粘性效果时用到。

注意:Layout attributes 对象可以被 collection view 拷贝。当然,在子类化 UICollectionViewLayoutAttributes 时必须实现 NSCopying 协议的特定方法,这样才能将你自定义的属性拷贝到新实例中。
如果你实现了自定义 layout attributes,你必须覆盖 isEqual 方法,对属性进行比较。从 iOS 7 开始,collection view 不会应用那些没有被修改的布局属性。

当前的 Collection view 还没有显示出所有的队伍。暂时,老虎、鹦鹉和长颈鹿这几只队伍还得等一等。

别担心。它们全都会回来的!CustomLayout 能够解决这个问题:]

UICollectionViewLayout 的职责

UICollectionViewLayout 的主要目标是提供关于 collection view 中每个元素的位置和可视化状态信息。请注意,UICollectionViewLayout 对象不会创建 cell 或者 supplementary view。它仅仅是用正确的属性提供它们。

创建一个自定义的 UICollectionViewLayout 分为 3 个步骤:

  1. 子类化抽象类 UICollectionViewLayout,并声明所有你需要进行布局计算的属性。
  2. 执行所有的计算,提供所有 collection view 的元素并正确设置它们的属性。这是最复杂的步骤,因为你必须从零开始实现 CollectionViewLayout 的核心逻辑。
  3. 让 collection view 使用新的 CustomLayout 类。

步骤1:子类化 UICollectionViewLayout

在 Custom Layout 文件夹下找到 CustomLayout.swift 文件,里面包含了一个空实现的 CustomLayout 类。我们将在这个类中实现 UICollectionViewLayout 子类,以及核心布局逻辑。

首先,声明需要用于计算 layout attributes 的属性:

import UIKit

final class CustomLayout: UICollectionViewLayout {

  // 1
  enum Element: String {
    case header
    case menu
    case sectionHeader
    case sectionFooter
    case cell

    var id: String {
      return self.rawValue
    }

    var kind: String {
      return "Kind\(self.rawValue.capitalized)"
    }
  }

  // 2
  override public class var layoutAttributesClass: AnyClass {
    return CustomLayoutAttributes.self
  }

  // 3
  override public var collectionViewContentSize: CGSize {
    return CGSize(width: collectionViewWidth, height: contentHeight)
  }

  // 4
  var settings = CustomLayoutSettings()
  private var oldBounds = CGRect.zero
  private var contentHeight = CGFloat()
  private var cache = [Element: [IndexPath: CustomLayoutAttributes]]()
  private var visibleLayoutAttributes = [CustomLayoutAttributes]()
  private var zIndex = 0

  // 5
  private var collectionViewHeight: CGFloat {
    return collectionView!.frame.height
  }

  private var collectionViewWidth: CGFloat {
    return collectionView!.frame.width
  }

  private var cellHeight: CGFloat {
    guard let itemSize = settings.itemSize else {
      return collectionViewHeight
    }

    return itemSize.height
  }

  private var cellWidth: CGFloat {
    guard let itemSize = settings.itemSize else {
      return collectionViewWidth
    }

    return itemSize.width
  }

  private var headerSize: CGSize {
    guard let headerSize = settings.headerSize else {
      return .zero
    }

    return headerSize
  }

  private var menuSize: CGSize {
    guard let menuSize = settings.menuSize else {
      return .zero
    }

    return menuSize
  }

  private var sectionsHeaderSize: CGSize {
    guard let sectionsHeaderSize = settings.sectionsHeaderSize else {
      return .zero
    }

    return sectionsHeaderSize
  }

  private var sectionsFooterSize: CGSize {
    guard let sectionsFooterSize = settings.sectionsFooterSize else {
      return .zero
    }

    return sectionsFooterSize
  }

  private var contentOffset: CGPoint {
    return collectionView!.contentOffset
  }
}

代码非常多,但非常简单,按照注释中的编号分别解释如下:

  1. 用一个枚举来列出所有 CustomLayout 的元素。这避免你直接使用字符串。记住这条金科玉律:不使用字符串=没有输入错误。
  2. layoutAttributesClass 是一个计算属性,允许通过类来使用 attributes 实例。你必须返回一个 CustomLayoutAttributes 的 class:你可以在开始项目中找到这个自定义类。
  3. collectionViewContentSize 是一个计算属性,UICollectionViewLayout 子类必须覆盖这个属性。
  4. CustomLayout 为了计算 attributes 必须用到这些属性。除了 settings 之外它们都是 fileprivate,因为 settings 会被外部对象修改。
  5. 一些计算属性,为了避免冗余代码而提供的语法糖。

声明完属性,接下来实现核心布局逻辑。

Step 2: 实现 CollectionViewLayout 核心逻辑

注意:如果你不熟悉核心布局逻辑,请阅读我们网站的关于自定义布局的这篇教程。后面的代码需要深刻理解核心布局流程。

collection view 直接用 CustomLayout 对象来管理整个布局过程。例如,collection view 会在第一次显示或大小改变时询问布局信息。

在布局过程中,collection view 会调用 CustomLayout 对象的 required 方法。而 optional 方法则在特殊情况下比如刷新动画时调用。这些方法允许你计算 item 的位置并告诉 collection view 它所需的一切信息。

最先需要覆盖的方法是:

  • prepare()
  • shouldInvalidateLayout(forBoundsChange:)

prepare() 方法中你可以对元素在布局中的位置进行计算。shouldInvalidateLayout(forBoundsChange:) 决定了 CustomLayout 对象何时需要或如何再次执行核心处理。

首先实现 prepare()。

打开 CustomLayout.swift 在文件最后添加新扩展:

// MARK: - LAYOUT CORE PROCESS
extension CustomLayout {

  override public func prepare() {

    // 1
    guard let collectionView = collectionView,
      cache.isEmpty else {
      return
    }
    // 2
    prepareCache()
    contentHeight = 0
    zIndex = 0
    oldBounds = collectionView.bounds
    let itemSize = CGSize(width: cellWidth, height: cellHeight)

    // 3
    let headerAttributes = CustomLayoutAttributes(
      forSupplementaryViewOfKind: Element.header.kind,
      with: IndexPath(item: 0, section: 0)
    )
    prepareElement(size: headerSize, type: .header, attributes: headerAttributes)

    // 4
    let menuAttributes = CustomLayoutAttributes(
      forSupplementaryViewOfKind: Element.menu.kind,
      with: IndexPath(item: 0, section: 0))
    prepareElement(size: menuSize, type: .menu, attributes: menuAttributes)

    // 5
    for section in 0 ..< collectionView.numberOfSections {

      let sectionHeaderAttributes = CustomLayoutAttributes(
        forSupplementaryViewOfKind: UICollectionElementKindSectionHeader,
        with: IndexPath(item: 0, section: section))
      prepareElement(
        size: sectionsHeaderSize,
        type: .sectionHeader,
        attributes: sectionHeaderAttributes)

      for item in 0 ..< collectionView.numberOfItems(inSection: section) {
        let cellIndexPath = IndexPath(item: item, section: section)
        let attributes = CustomLayoutAttributes(forCellWith: cellIndexPath)
        let lineInterSpace = settings.minimumLineSpacing
        attributes.frame = CGRect(
          x: 0 + settings.minimumInteritemSpacing,
          y: contentHeight + lineInterSpace,
          width: itemSize.width,
          height: itemSize.height
        )
        attributes.zIndex = zIndex
        contentHeight = attributes.frame.maxY
        cache[.cell]?[cellIndexPath] = attributes
        zIndex += 1
      }

      let sectionFooterAttributes = CustomLayoutAttributes(
        forSupplementaryViewOfKind: UICollectionElementKindSectionFooter,
        with: IndexPath(item: 1, section: section))
      prepareElement(
        size: sectionsFooterSize,
        type: .sectionFooter,
        attributes: sectionFooterAttributes)
    }

    // 6
    updateZIndexes()
  }
}

分段解释如下:

  1. prepare 方法是一个资源密集型的操作,会影响性能。因此,你要将计算过的 attributes 缓存起来。在继续之前,你必须判断 cache 字典是否为空。对于避免将新老 attributes 实例搞混来说,这是至关紧要的。
  2. 如果 cache 字典为空,你必须进行适当的初始化。这是通过调用 prepareCache() 来完成的。这个方法在随后实现。
  3. 弹性 header 是 collection view 的第一个元素。因此,你需要首先计算它的 attributes。你新建了一个 CustomLayoutAttributes 实例,并传递给 prepareElement(size:type:attributes)方法。这个方法也在随后实现。这里需要注意的是每当你新建一个自定义元素时,都必须调用这个方法,这样才会缓存它的 attributes。
  4. 粘性菜单是 collection view 的第二个元素。用和前面一样的方法计算它的属性。
  5. 这个循环是核心布局处理中的最重要的部分。对于 Collection view 中每个 section 的每个 item 你都需要:

    • 创建并缓存 section 头的 attributes。
    • 创建 item 的 attributes。
    • 将它们和某个 indexPath 关联。
    • 计算并设置 item 的 frame 和 zIndex。
    • 更新 UICollectionView 的 contentHeight。
    • 将新的 attributes 放到缓存字典,用 type(这里就是 cell)和 indexPath 作为元素的 key。
    • 最后,创建并准备 section 尾的 attributes。
  6. 最后但同样也很重要的是,调用一个方法,刷新所有 zIndex 值。后面会介绍 updateZIndexes() 方法以及它的重要性。

然后,添加方法:

override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
  if oldBounds.size != newBounds.size {
    cache.removeAll(keepingCapacity: true)
  }
  return true
}

在 shouldInvalidateLayout(forBoundsChange:) 方法中,你必须决定何时以及如何让 prepare() 方法中的计算失效。Collection View会在它的 bounds 属性发生改变时调用这个方法。注意,当用户滚动时,collection view 的 bounds 属性会发生改变。

这个方法总是返回 true,同时当 bounds 的 size 发生改变,意味着 collection view 从竖屏变到横屏或相反,你也需要清洗缓存字典。

清洗缓存很有必要,因为设备方向发生改变会触发 collection view 的 frame 重绘。这会导致原本缓存的 attributes 不再适配 collection view 的新 frame。

接着,你需要实现在 prepare() 中调到但还没有实现的方法。

在扩展的底部添加方法:

private func prepareCache() {
  cache.removeAll(keepingCapacity: true)
  cache[.header] = [IndexPath: CustomLayoutAttributes]()
  cache[.menu] = [IndexPath: CustomLayoutAttributes]()
  cache[.sectionHeader] = [IndexPath: CustomLayoutAttributes]()
  cache[.sectionFooter] = [IndexPath: CustomLayoutAttributes]()
  cache[.cell] = [IndexPath: CustomLayoutAttributes]()
}

这个方法首先是清空 cache 字典。然后重新添加所有的嵌套字典,每种元素一个,用元素的类型作为主键。indexPath 作为第二个键,用于唯一识别所保存的 attributes。

然后,需要实现 prepareElement(size:type:attributes:) 方法。

在扩展最后添加方法:

private func prepareElement(size: CGSize, type: Element, attributes: CustomLayoutAttributes) {
  //1
  guard size != .zero else {
    return
  }
  //2
  attributes.initialOrigin = CGPoint(x:0, y: contentHeight)
  attributes.frame = CGRect(origin: attributes.initialOrigin, size: size)
  // 3
  attributes.zIndex = zIndex
  zIndex += 1
  // 4
  contentHeight = attributes.frame.maxY
  // 5
  cache[type]?[attributes.indexPath] = attributes
}

分段解释如下:

  1. 判断元素的 size 是否有效。如果一个元素没有大小,不需要缓存它的 attributes。
  2. 然后将 frame 的 origin 设置为 attributes 的 initialOrigin。备份元素的原始坐标是必要的,因为在后面计算视差和粘性转换动画时要用到。
  3. 然后,设置 zIndex 值,防止不同元素之间相交。
  4. 一旦建好并保存完所需的信息,需要修改 collection view 的 contentHeight,因为 UICollectionView 中添加了新元素。简单办法就是将 attributes 的 frame 的 maxY 赋给 contentHeight。
  5. 将 attributes 添加到 cache 字典,用元素的类型和 indexPath 作为唯一键。

最后,实现 prepare() 方法中调到的 updateZIndexes() 方法。

在扩展最后添加:

private func updateZIndexes(){
  guard let sectionHeaders = cache[.sectionHeader] else {
    return
  }
  var sectionHeadersZIndex = zIndex
  for (_, attributes) in sectionHeaders {
    attributes.zIndex = sectionHeadersZIndex
    sectionHeadersZIndex += 1
  }
  cache[.menu]?.first?.value.zIndex = sectionHeadersZIndex
}

这个方法将 zIndex 值依序递增后赋给每个 section 头。这个数字的初值是最后一个 cell 的 zIndex。最大的 zIndex 值被赋给 Menu 的 attributes。对于要实现的粘性效果来说,这样的重置是必要的。如果不调用这个方法,则一个 section 的 cell 的 zIndex 会比 section 头的 zIndex 还要大。这会在滚动时出现奇怪的交叠现象。

为了完成 CustomLayout 类以及核心布局处理,还需要实现几个 required 的方法:

  • layoutAttributesForSupplementaryView(ofKind:at:)
  • layoutAttributesForItem(at:)
  • layoutAttributesForElements(in:)

这些方法的目标是在正确的时间提供正确的 attributes 给对应的元素。尤其是前两个方法,为 collection view 的每个 supplementary view 或 cell 提供 attributes。第三个方法在给定时间返回要显示的元素的 attributes。

//MARK: - PROVIDING ATTRIBUTES TO THE COLLECTIONVIEW
extension CustomLayout {

  //1
  public override func layoutAttributesForSupplementaryView(
    ofKind elementKind: String,
    at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {

  switch elementKind {
    case UICollectionElementKindSectionHeader:
      return cache[.sectionHeader]?[indexPath]

    case UICollectionElementKindSectionFooter:
      return cache[.sectionFooter]?[indexPath]

    case Element.header.kind:
      return cache[.header]?[indexPath]

    default:
      return cache[.menu]?[indexPath]
    }
  }

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

  //3
  override public func layoutAttributesForElements(
    in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
      visibleLayoutAttributes.removeAll(keepingCapacity: true)
      for (_, elementInfos) in cache {
        for (_, attributes) in elementInfos where attributes.frame.intersects(rect) {
          visibleLayoutAttributes.append(attributes)
        }
      }
      return visibleLayoutAttributes
  }
}

按照注释中的编号顺序进行说明如下:

  1. 在 layoutAttributesForSupplementaryView(ofKind:at:) 方法中,对元素的 kind 属性进行判断,根据正确的 kind 和 indexPath 返回缓存的 attributes。
  2. 在layoutAttributesForItem(at:) 方法中,和前一个方法实际上没什么两样。
  3. 在 layoutAttributesForElements(in:) 方法中,清空了 visibleLayoutAttributes 数组(这里面保存了可见的 attributes)。然后,对缓存的 attributes 进行迭代,将可见的元素的 attributes 添加到 visibleLayoutAttributes 中。要判断一个元素是否可见,可以判断它的 frame 是否和 collection view 的 frame 相交。然后返回 visibleAttributes 数组。

步骤 3:应用 CustomLayout

还需要下面几个步骤你才能 Build & run:

  • 让 collection view 应用 CustomLayout 类。
  • 让 JungleCupCollectionViewController 支持自定义 supplementary view。

打开 Main.storyboard ,在 Jungle Cup Collection View Controrller 场景中,选择 Collection View Flow Layout:

自定义视差 UICollectionViewLayout 教程_第2张图片

然后,打开 Identity 模板,将 Custom Class 修改为 CustomLayout:

自定义视差 UICollectionViewLayout 教程_第3张图片

接着,打开 JungleCupCollectionViewController.swift。

为了减少冗余代码,添加一个 customLayout 的计算属性。

var customLayout: CustomLayout? {
  return collectionView?.collectionViewLayout as? CustomLayout
}

然后将 setUpCollectionViewLayout() 方法修改为:

  private func setupCollectionViewLayout() {
    guard let collectionView = collectionView,
      let customLayout = customLayout else {
        return
    }
    // 1
    collectionView.register(
        UINib(nibName: "HeaderView", bundle: nil),
        forSupplementaryViewOfKind: CustomLayout.Element.header.kind,
        withReuseIdentifier: CustomLayout.Element.header.id
    )
    collectionView.register(
        UINib(nibName: "MenuView", bundle: nil),
        forSupplementaryViewOfKind: CustomLayout.Element.menu.kind,
        withReuseIdentifier: CustomLayout.Element.menu.id
    )

    // 2
    customLayout.settings.itemSize = CGSize(width: collectionView.frame.width, height: 200)
    customLayout.settings.headerSize = CGSize(width: collectionView.frame.width, height: 300)
    customLayout.settings.menuSize = CGSize(width: collectionView.frame.width, height: 70)
    customLayout.settings.sectionsHeaderSize = CGSize(width: collectionView.frame.width, height: 50)
    customLayout.settings.sectionsFooterSize = CGSize(width: collectionView.frame.width, height: 50)
    customLayout.settings.isHeaderStretchy = true
    customLayout.settings.isAlphaOnHeaderActive = true
    customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0)
    customLayout.settings.isMenuSticky = true
    customLayout.settings.isSectionHeadersSticky = true
    customLayout.settings.isParallaxOnCellsEnabled = true
    customLayout.settings.maxParallaxOffset = 60
    customLayout.settings.minimumInteritemSpacing = 0
    customLayout.settings.minimumLineSpacing = 3
}

代码说明:

  1. 首先,注册两个自定义类,一个用于弹性 header,一个用于自定义菜单。在开始项目中已经实现了这两个 UICollecdtionReusableView 子类。
  2. 最后,设置 CustomLayout 的 settings 的大小、行为和间距。

在 Build & run 之前,在 viewForSupplementaryElementOfKind(_:viewForSupplementaryElementOfKind:at:) 方法中添加两个 case 分支,用于处理自定义的 supplementary view 类型:

case CustomLayout.Element.header.kind:
  let topHeaderView = collectionView.dequeueReusableSupplementaryView(
    ofKind: kind,
    withReuseIdentifier: CustomLayout.Element.header.id,
    for: indexPath)
  return topHeaderView

case CustomLayout.Element.menu.kind:
  let menuView = collectionView.dequeueReusableSupplementaryView(
    ofKind: kind,
    withReuseIdentifier: CustomLayout.Element.menu.id,
    for: indexPath)
  if let menuView = menuView as? MenuView {
    menuView.delegate = self
  }
  return menuView

好极了!经过漫长的煎熬,你终于要完成了。

Build & run!你会看到:

开始项目中的 collection view 现在多出了一些东西:

  1. 头部有一个巨大的 Header,用于显示丛林杯的 Logo。
  2. 在它下面,有一个 4 个按钮的菜单,对应 4 支参赛队伍。当你点击某个按钮,collection view 会加载该球队的数据。

你做得不错!你还可以更好。我们将添加一些漂亮的可视化效果,为我们的 collection view 锦上添花。

添加弹性、粘性和视差效果

在最后一节,我们将添加如下视觉效果:

  1. 为 Header 增加弹性特效。
  2. 为 Menu 和 section header 添加粘性效果。
  3. 实现视差效果,让我们的 UI 更吸睛。

注意:如果你不熟悉 CGATransform,你可以阅读这篇教程再来继续。接下来的内容需要具备基本的仿射转换技巧。

仿射转换

Core Graphics 的 CGAffineTransform API 是在 collection view 元素上添加视觉效果的最佳手段。

仿射转换非常好用,因为:

  1. 它允许你创建复杂的视觉效果,比如平移、缩放、旋转,或者三者之和,但需要的代码却非常少。
  2. 它能无缝地插入到 UIKit 组件和自动布局之间。
  3. 哪怕在复杂场景下,它也能保持最佳性能。

仿射转换后面的数学计算非常精彩。但关于 CGATransform 背后的矩阵是如何运算不属于本文范畴。

如果你对这个内容感兴趣,请参考苹果的Core Graphics 框架文档。

转换视觉相关的 attributes

打开 CustomLayout.swift 修改 layoutAttributesForElements(in:) 方法:

override public func layoutAttributesForElements(
  in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

    guard let collectionView = collectionView else {
      return nil
    }
    visibleLayoutAttributes.removeAll(keepingCapacity: true)
    // 1
    let halfHeight = collectionViewHeight * 0.5
    let halfCellHeight = cellHeight * 0.5
    // 2
    for (type, elementInfos) in cache {
      for (indexPath, attributes) in elementInfos {
        // 3
        attributes.parallax = .identity
        attributes.transform = .identity
        // 4
        updateSupplementaryViews(
          type,
          attributes: attributes,
          collectionView: collectionView,
          indexPath: indexPath)
        if attributes.frame.intersects(rect) {
          // 5
          if type == .cell,
            settings.isParallaxOnCellsEnabled {
              updateCells(attributes, halfHeight: halfHeight, halfCellHeight: halfCellHeight)
          }
          visibleLayoutAttributes.append(attributes)
        }
      }
    }
    return visibleLayoutAttributes
}

代码解释如下:

  1. 保存几个有用的值,这样就不需要在循环中反复计算它们。
  2. 和之前的循环一样,遍历所有缓存的 attribute。
  3. 重置视差转换和 transform 为默认值。
  4. 调用刷新不同 supplementary view 的方法。这个方法随后会实现。
  5. 判断当前 attributes 是否属于 cell 的。如果布局 settings 中的视差效果是打开的,调用一个方法刷新 cell 的 attributes。同前面一样,随后会实现相关方法。

然后,实现上面提到的两个方法:

  • updateSupplementaryViews(_:attributes:collectionView:indexPath:)
  • updateCells(_:halfHeight:halfCellHeight:)

继续添加:

private func updateSupplementaryViews(_ type: Element,
                                      attributes: CustomLayoutAttributes, 
                                      collectionView: UICollectionView,
                                      indexPath: IndexPath) {
    // 1
    if type == .sectionHeader,
      settings.isSectionHeadersSticky {
        let upperLimit = 
           CGFloat(collectionView.numberOfItems(inSection: indexPath.section))
           * (cellHeight + settings.minimumLineSpacing)
        let menuOffset = settings.isMenuSticky ? menuSize.height : 0
        attributes.transform =  CGAffineTransform(
          translationX: 0,
          y: min(upperLimit,
          max(0, contentOffset.y - attributes.initialOrigin.y + menuOffset)))
    }
    // 2
    else if type == .header,
      settings.isHeaderStretchy {
        let updatedHeight = min(
          collectionView.frame.height,
          max(headerSize.height, headerSize.height - contentOffset.y))
        let scaleFactor = updatedHeight / headerSize.height
        let delta = (updatedHeight - headerSize.height) / 2
        let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
        let translation = CGAffineTransform(
          translationX: 0,
          y: min(contentOffset.y, headerSize.height) + delta)
        attributes.transform = scale.concatenating(translation)
        if settings.isAlphaOnHeaderActive {
          attributes.headerOverlayAlpha = min(
            settings.headerOverlayMaxAlphaValue,
            contentOffset.y / headerSize.height)
        }
    }
    // 3
    else if type == .menu,
      settings.isMenuSticky {
        attributes.transform = CGAffineTransform(
          translationX: 0,
          y: max(attributes.initialOrigin.y, contentOffset.y) - headerSize.height)
    }
  }

代码说明如下:

  1. 判断当前元素是否是 section header,以及 settings 中的粘性行为是否开启,计算动画。将计算好的值应用到 attributes 的 transform 属性。
  2. 同上一步,但这次是针对 collection view 的 Header。如果弹性效果开启,进行这些动画计算。
  3. 同上一步。这是针对粘性菜单的。

然后对 cell 进行动画:

private func updateCells(_ attributes: CustomLayoutAttributes,
                         halfHeight: CGFloat,
                         halfCellHeight: CGFloat) {
  // 1
  let cellDistanceFromCenter = attributes.center.y - contentOffset.y - halfHeight

  // 2
  let parallaxOffset = -(settings.maxParallaxOffset * cellDistanceFromCenter)
    / (halfHeight + halfCellHeight)
  // 3 
  let boundedParallaxOffset = min(
    max(-settings.maxParallaxOffset, parallaxOffset),
    settings.maxParallaxOffset)
  // 4
  attributes.parallax = CGAffineTransform(translationX: 0, y: boundedParallaxOffset)
}

代码解释如下:

  1. 计算 cell 到 collection view 中心的距离。
  2. 将这个距离根据最大视差值(在 settings 中设置的)进行转换。
  3. 对 parallaxOffset 进行限制,避免一些视觉问题。
  4. 用计算出的视差值创建 CAAfineTransform。然后赋给 cell 的 attributes 的 transfomr 属性。

要在 PlayerCell 上创建视差效果,应当将图片的 frame 添加一个 top 和 bottom 为负值的 insets。开始项目已经设置好这个约束了。你可以在约束面板里面查看一下。

自定义视差 UICollectionViewLayout 教程_第4张图片

Build & run 之前,还有一个地方。打开 JungleCupCollectionViewController.swift。

在 setupCollectionViewLayout() 将:

customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0)

修改为:

customLayout.settings.headerOverlayMaxAlphaValue = CGFloat(0.6)

这个属性表示 headerView 上方黑色的遮罩层的最大透明度。

Build & run 查看视觉效果。我拉、我拉、我拉拉拉!

结束

最终完成的项目请从此处下载。

通过几行代码和一些基本的转换动画,你就创建出了一个完全自定义和可配置的 UICollectionViewLayout,你可以将它使用在今后项目中的任何地方!

如果你想学习更多自定义 UICollectionViewLayout 的技术,请阅读 iOS Collection View 编程指南 中的创建自定义 Layout一节,它充分讨论了这个主题。

希望你喜欢本教程!有任何问题和建议,请在下面留言。

(丛林杯 Logo 所用的动物矢量图来自于 www.freevector.com)。

你可能感兴趣的:(iPhone开发)