在使用collectionView进行布局时,经常会遇到需要对collectionViewlayout自定义化才能满足的布局需求,比如瀑布流、左对齐、悬停等等。在你自定义化的layout里面,需要计算元素的布局属性缓存起来,再交由系统去绘制,你可以认为 UICollectionViewFlowLayout 就是这么做的。
UICollectionViewFlowLayout 是官方给的流式布局,它继承于 UICollectionViewLayout,提供了额外一套需要实遵循的代理协议UICollectionViewDelegateFlowLayout(虽然简单情况下可以用设置属性的方法代替),这个协议需要开发者提供行间距、元素间距、元素尺寸、组内边距等等。显然在我们创建自定义layout时,也是需要通过一套协议来获取计算布局时所需要的信息。
我们先创建一个 UICollectionLayout 的子类,简单命名为CustomizeLayout。下面代码用Swift语言。
重写父类属性:
collectionViewContentSize: CGSize
重写父类方法:
prepare()
layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
当然如果对collectionView的动画insert、delete等有要求的话,也要重写方法
prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem])
创建自定义layout的基本流程:
创建用来存储布局属性的数组:
private var attrsArr: [UICollectionViewLayoutAttributes] = []
在下面方法里计算好布局属性并添加到数组里:
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
通过这个方法交给collectionView去绘制:
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {}
就这么简单
一:布局属性 UICollectionViewLayoutAttributes
基本上collectionView里的每个cell和每个headerView、footerView都对应着一个布局属性,你需要在 CustomizeLayout 里计算好每个布局属性并存放起来,在适当的时候交给collectionView去绘制。
当然可能因为计算需要,CustomizeLayout 会有其它属性,比如垂直瀑布流会储存每列的高度用来安排下一个元素所添加的位置。
二:计算布局属性 layoutAttributesForItem 和 layoutAttributesForSupplementaryView
这两个方法的性质是一样的,只是一个负责计算 cell 的布局属性,一个负责 supplementaryView 的布局属性,就是所谓头部跟尾部,开发者需要创建 UICollectionViewLayoutAttributes 的对象attrs,再根据参数 indexPath 计算元素的位置 frame 后赋值到 attrs.frame 下,再把 attrs 缓存起来交由 collectionView 绘制就好了。
有趣的是,系统会不定时调用这两个方法去满足它的绘制需求。如果不做一些判断拦截操作,你会发现很多布局都重复计算了,这也是为什么存储布局属性的操作也放这里面的原因。所以,开发者的操作应该是:先在缓存数组里寻找现有的布局属性,找到就返回,找不到的话就创建新的并储存起来。这样既能避免重复计算、又能保证attrsArr里面每个布局属性都是唯一的。Cell代码如下
// layoutAttributesForItem
// 先寻找现有的布局属性
for attrs in self.attrsArr {
if attrs.indexPath == indexPath, attrs.representedElementCategory == .cell {
return attrs
}
}
// 没有的话就创建并添加,frameOfItem为自定义方法。
let attrs = UICollectionViewLayoutAttributes.init(forCellWith: indexPath)
attrs.frame = self.frameOfItem(indexPath: indexPath)
self.attrsArr.append(attrs)
return attrs
对于计算过程,两个方法的都有元素下标 IndexPath 作为参数,但并不是每个元素的位置都取决于下标,比如垂直瀑布流是算一个放一个,就需要借助其它方法来进行计算。
计算中可能需要创建代理 CustomizeDelegateLayout 去获取需要的信息,比如垂直瀑布流就需要总列数,每一列的宽度,每列之间的间隔、元素的宽高比例等等。看起来是不是跟 UICollectionViewDelegateFlowLayout 所需要的截然不同。再比如做悬停的话,就需要悬停的元素下标、悬停的高度等等。
三:交由系统绘制 layoutAttributesForElements
这步就简单的多,但我看到一些别人自定义的layout直接返回了已缓存的所有布局属性,就是直接 return self.attrsArr,这样是不对的。因为布局属性只会越来越多,数组越来越大,系统处理的负担也越来越重。在某些低配一点的机型,当元素个数到达一定程度的时候,会造成程序奔溃,真机调试下连断点都没有,伴随着 memory issues 直接终止调试。
正确的做法是考虑参数rect,系统只需要rect这个位置下的布局属性,并不是需要全部,所以我们可以抽取部分布局属性来返回,代码如下:
// 只返回rect下所需要的布局属性,减少系统处理成本
var output: [UICollectionViewLayoutAttributes] = []
for attrs in self.attrsArr {
if attrs.frame.maxY > rect.minY && attrs.frame.minY < rect.maxY {
output.append(attrs)
}
if attrs.frame.minY > rect.maxY {
break
}
}
return output
这样系统每次需要绘制的属性其实很少。作为实验,你可以创建一个 UICollectionViewFlowLayout 的子layout,重写里面的 layoutAttributesForElements,用 super.layoutAttributesForElements 去观察父类每次返回的布局属性,你会发现某些元素在超出屏幕一定范围后,它的布局属性就不出现在这个方法的输出数组里了。说明官方给的流式布局也是类似的处理思路,只是返回的数量比上述代码返回的量要大一些。
其它
prepare() 方法在每次collectionView.reloadData() 的时候都会首先执行,用作绘制的一些准备,开发者可以在里面做一些属性复原操作。但需要注意的时,当collectionView在执行insert、delete等动画时也会经过这个方法,然后才进入 prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) 方法。此时复原操作会使得布局混乱了起来,因为你只是在做更新,没想让它复原。
刚说的悬停可以通过自定义layout来实现。但更简单一点,如果布局没有其它什么要求的话,你可以直接创建一个继承 UICollectionViewFlowLayout 的子类,不需要自己去管理布局属性的存储,只需重写 layoutAttributesForElements 跟 layoutAttributesForSupplementaryView 或 layoutAttributesForItem 。你需要在计算方法里算好需要悬停的元素根据滑动偏移量所决定的位置frame,然后在 layoutAttributesForElements 方法里,把这个属性加到 super.layoutAttributesForElements 的输出数组里再返回。因为上面有提到,元素在超出屏幕一定范围后就不会出现在 layoutAttributesForElements 的输出数组里,所以你需要悬停的元素永远存在,就得每次都要放进输出数组里。下面代码为固定悬停section1的头部
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// 父类布局属性
guard let attrs = super.layoutAttributesForElements(in: rect) else {
return nil
}
// 新布局属性
var newAttrs: [UICollectionViewLayoutAttributes] = []
for attr in attrs {
if attr.representedElementCategory == .cell {
// 如果是cell就原封不动加上
newAttrs.append(attr)
} else if attr.representedElementCategory == .supplementaryView && attr.indexPath.section != 1 {
// 如果不是需要悬停的头部也加上
newAttrs.append(attr)
}
}
// 强制补充悬停视图
let indexPath = IndexPath.init(item:0, section:1)
if let attr = self.layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: indexPath) {
attr.zIndex = 1
newAttrs.append(attr)
}
return newAttrs
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
// 父类布局属性
guard let attr = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) else { return nil }
// 如果不是头部就返回
if elementKind != UICollectionView.elementKindSectionHeader {
return attr
}
// 视图垂直偏移量
let contentOffsetY = collectionView!.contentOffset.y
// 如果滑动到顶部以上(由视图布局决定)就补上差值
if attr.frame.minY - contentOffsetY < RootViewController.current!.topHeight
attr.frame.origin.y = contentOffsetY + RootViewController.current!.topHeight
}
return attr
}