原文: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 的核心布局流程。
如果你不了解以上主题,请阅读苹果官方文档…
…或者,阅读本站的优秀教程!
通过本教程的学习,你将实现类似如下的 Collection View:
准备好一举拿下“丛林杯”了吗?让我们开始吧!
下载开始项目,用 Xcode 打开它。Build & run。
你会看到几只酷酷的猫头鹰显示在一个带 section 头和尾的标准 Collection View 里:
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 实现了一个包含所有布局设置的结构。第一组设置和 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 的主要目标是提供关于 collection view 中每个元素的位置和可视化状态信息。请注意,UICollectionViewLayout 对象不会创建 cell 或者 supplementary view。它仅仅是用正确的属性提供它们。
创建一个自定义的 UICollectionViewLayout 分为 3 个步骤:
在 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
}
}
代码非常多,但非常简单,按照注释中的编号分别解释如下:
声明完属性,接下来实现核心布局逻辑。
注意:如果你不熟悉核心布局逻辑,请阅读我们网站的关于自定义布局的这篇教程。后面的代码需要深刻理解核心布局流程。
collection view 直接用 CustomLayout 对象来管理整个布局过程。例如,collection view 会在第一次显示或大小改变时询问布局信息。
在布局过程中,collection view 会调用 CustomLayout 对象的 required 方法。而 optional 方法则在特殊情况下比如刷新动画时调用。这些方法允许你计算 item 的位置并告诉 collection view 它所需的一切信息。
最先需要覆盖的方法是:
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()
}
}
分段解释如下:
这个循环是核心布局处理中的最重要的部分。对于 Collection view 中每个 section 的每个 item 你都需要:
然后,添加方法:
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
}
分段解释如下:
最后,实现 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 的方法:
这些方法的目标是在正确的时间提供正确的 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
}
}
按照注释中的编号顺序进行说明如下:
还需要下面几个步骤你才能 Build & run:
打开 Main.storyboard ,在 Jungle Cup Collection View Controrller 场景中,选择 Collection View Flow Layout:
然后,打开 Identity 模板,将 Custom Class 修改为 CustomLayout:
接着,打开 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
}
代码说明:
在 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 现在多出了一些东西:
你做得不错!你还可以更好。我们将添加一些漂亮的可视化效果,为我们的 collection view 锦上添花。
在最后一节,我们将添加如下视觉效果:
注意:如果你不熟悉 CGATransform,你可以阅读这篇教程再来继续。接下来的内容需要具备基本的仿射转换技巧。
Core Graphics 的 CGAffineTransform API 是在 collection view 元素上添加视觉效果的最佳手段。
仿射转换非常好用,因为:
仿射转换后面的数学计算非常精彩。但关于 CGATransform 背后的矩阵是如何运算不属于本文范畴。
如果你对这个内容感兴趣,请参考苹果的Core Graphics 框架文档。
打开 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
}
代码解释如下:
然后,实现上面提到的两个方法:
继续添加:
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)
}
}
代码说明如下:
然后对 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)
}
代码解释如下:
要在 PlayerCell 上创建视差效果,应当将图片的 frame 添加一个 top 和 bottom 为负值的 insets。开始项目已经设置好这个约束了。你可以在约束面板里面查看一下。
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)。