iOS Collection View 编程指导(五)-创建自定义Layout
在创建自定义layout之前, 考虑是否可使用UICollectionViewFlowLayout, 因为flow layout是苹果精心设计的, 里面考虑了性能优化, 易扩展等特性. 而且大多数布局都可以使用flow layout来解决, 除非以下两种情况才需要自定义布局:
- 你的布局不是基于线性断裂式布局(item按照行排列, 填满一行接着下一行), 或者collectionView中内容可以在一个以上的方向上滚动
- 你需要频繁的改变cell的位置, 如果使用flow layout的话工作量很大, 此时考虑自定义布局
从API层面上考虑的话, 实现自定义布局是比较简单的, 除了计算item的位置(position)和大小(size)比较难外, 其他的工作都很简单.
继承UICollectionViewLayout
UICollectionViewLayout
类提供给你一个干净良好的自定义布局环境. 该类中的少数方法是必须重写的, 这些方法确定了layout对象的核心行为. 剩余的方法是非必须的, 按照你自己的需要去实现. 以下列举了你需要实现的两个重要任务:
- 确定可滚动的内容区域大小
- 提供cell和view的layout attribute对象, 以确定collectionView中的cell和view的外观和位置.
虽然你可以仅实现必须要实现的几个方法, 但如果你的自定义layout对象还实现了其他方法, 那么你的自定义layout对象将更加完善.
layout对象使用DataSource提供的信息来创建collectionView的布局. layout对象通过它的属性collectionView
来访问DataSource, 该属性指与当前布局对象绑定的collectionView, 在layout类中任何位置都可以访问. 在布局过程, 你需要注意, 使用collectionView能获取啥和不能获取啥. 因为布局发生在后台, 所以collectionView无法知道view的layout或者位置. 所以, 即使layout对象没有限制你在布局时调用collectionView的方法, 除了获取布局对象需要的很布局相关的data以外, 你应该避免去调用collectionView的其他方法.
理解布局过程的关键部分
collectionView能够直接使用自定义layout来布局. 当collectionView需要布局时(初次显示/resize), 会要求layout对象提供布局信息. 也可以通过调用invalidateLayout方法来显示地通知collectionView更新布局. 该方法会使collectionView丢弃现有的布局信息并让layout对象产生信息的布局信息提供给collectionView.
注意:不要把layout的
invalidateLayout
方法和collectionView的reloadData
方法弄混淆了. 调用invalidateLayout
方法时, collectionView中的cell和view都不变, 变的是布局信息, 因为layout对象会重新计算一次所有item的layout attribute. 调用reloadData
方法, 是因为DataSource中的数据有变化, collectionView的布局信息不会变.
在布局过程中, collectionView会调用layout对象的一些方法, 在这些方法里面, 你可以计算item的位置和其他collectionView要用的一些关键信息, 部分方法可能调用, 但下面几个布局步骤方法一定会调用:
- 使用
prepareLayout
方法来做一些布局前的准备, 计算布局信息. - 使用
collectionViewContentSize
方法来提供全部内容区域大小. - 使用
layoutAttributesForElementsInRect:
方法来提供特定区域内cell和view的attribute对象
图5-1, 展示了如何使用上面方法来生成布局信息
- 在
prepareLayout
方法中, 你需要计算任何可以确定cell和view的位置的信息, 至少需要计算出内容大小的信息, 该信息在步骤2的方法中被返回. - collectionView使用content size来配置scrollView. 举个例子, 如果你计算后的contentSize在水平和竖直方向上都超出屏幕, scrollView可以同时在两个方向上滚动. 但是如果你使用的flow layout的话, 只能在一个方向滚动.
- 基于当前的滚动位置, collectionView调用
layoutAttributesForElementsInRect:
方法来获取特定矩形区域内的cell和view的attribute信息, 该矩形区域可能和可见区域相同, 也可能不同. 当信息获取后, 代表布局过程已经完成. - 当布局完成后, cell和view的attribute信息保持不变, 直到你主动或者collectionView调用
invalidateLayout
方法来重启布局过程. 调用了layout的invalidateLayout
方法后, 会再次开启布局过程,prepareLayout
方法会被重新调用. 当你滚动collectionView中的内容时, collectionView调用layout对象的shouldInvalidateLayoutForBoundsChange:
方法, 如果该方法返回YES, 那么会invalidate你的layout.
注意:值得注意的是, 你去调用
invalidateLayout
方法时, layout不会立即更新, 而是标记当前的layout已经和数据不一致了, 需要更新, 当下一次view刷新时才会去更新. 在更新过程中, collectionView会检查它的layout已经过时了, 如果是就更新layout. 事实上, 如果你在一个点连续调用invalidateLayout
多次也不会立即更新layout.
创建Layout Attribute
attribute对象是UICollectionViewLayoutAttribute
的实例, 由layout对象负责创建. 当APP处理大量的item时, 在准备布局时创建attribute对象非常有用, 因为attribute记录了item的布局信息而且可以缓存起来. 计算attribute属性非常消耗时, 所以缓存的将变得有意义, 这样在布局过程中可以随时创建attribute对象. 你可以使用下面的类方法来创建UICollectionViewLayoutAttribute
实例:
- layoutAttributesForCellWithIndexPath:
- layoutAttributesForSupplementaryViewOfKind:withIndexPath:
- layoutAttributesForDecorationViewOfKind:withIndexPath:
在创建attribute对象时, 你必须根据view类型来选择正确的类方法, 因为collectionView使用attribute对象从DataSource中获取相应的view, 如果你的类方法使用错误, 那么创建的view也会错误, layout也是失效
在创建attribute对象之后, 你可以给attribute的属性赋值, 如果系统的类的属性不能满足需求, 你可以自定义一个attribute对象. 比如你可以给attribute对象复制size和position, 这些值在接下来的布局中会用到, 你要控制view的层叠顺序可以使用zIndex
属性来控制. 另外在定义的attribute对象中, 你需要实现isEqual:
方法, 因为在某些时候, collectionView会用来比较两个attribute.
如果想知道更多关于layout attribute的信息, 请看该对象的API文档UICollectionViewLayoutAttributes Class Reference
准备布局
在布局时, layout对象会先调用prepareLayout
方法开启布局过程. 在这个方法内, 你可以计算布局信息供后面使用. 在自定义layout中, prepareLayout
不是必须实现的, 调用该方法之后, 布局对象有了足够的信息去计算collectionView的contentSize. 你还可以在该方法中计算attribute对象属性值.
给指定矩形区域内的item提供Layout Attribute
在布局的最后一步是调用layout对象的layoutAttributesForElementsInRect:
方法来获取特定矩形中的cell和view的Attribute信息. 对于collectionView来说, 该特定的矩形区域一般指可见区域(visible rect), 如图5-2所示的cell6到cell20和header2. 在该方法内, 你需要返回这些view的Attribute信息.
在方法prepareLayout
调用时就应该计算方法layoutAttributesForElementsInRect:
需要的信息. 实现layoutAttributesForElementsInRect:
的步骤如下:
- 获取方法
prepareLayout
计算的数据, 然后获取缓存的Attribute对象或者新创建一个 - 检查item的frame, 确定该item在给定的矩形区域内
- 将item对应的
UICollectionViewLayoutAttribute
实例添加到一个数组 - 将上面的数组返回给collectionView
你是在prepareLayout
创建Attribute还是等到在layoutAttributesForElementsInRect:
创建, 取决于你如何管理的布局信息. 但你需要考虑缓存Attribute的好处. 当你collectionView中的item太多时, 等到请求Attribute时再创建Attribute更好点.
注意: 有时layout对象会为个别item提供Attribute, 比如在布局过程外时, collectionView就可能为了做某些动画来请求个别item的Attribute. 详情请看Providing Layout Attributes On Demand
这里有个demo用来讲解这些过程,请看Providing Layout Attributes
按需提供Layout Attribute
collectionView有时需要layout提供Attribute来做插入/删除特定item的动画, layout提供了下面方法来实现此项功能:
- layoutAttributesForItemAtIndexPath:
- layoutAttributesForSupplementaryViewOfKind:atIndexPath:
- layoutAttributesForDecorationViewOfKind:atIndexPath:
在这些方法中, 你需要返回当前layout对象中对应item的Attribute信息, layoutAttributesForItemAtIndexPath:
必须实现, 其他两个是可选的, 返回Attribute后, 你不要更新Attribute对象, 如果你的layout改变了, 使用invalidateLayout布局即可.
使用自定义布局
可以通过代码和storyboard来使用自定义布局. 你可以使用collectionView的属性collectionViewLayout来绑定你的布局对象, 如下代码清单5-1所示:
代码清单5-1 使用自定义布局
self.collectionView.collectionViewLayout = [[MyCustomLayout alloc] init];
在storyboard中使用自定义布局, 请看图5-a:
让自定义layout更强悍
自定义layout必须给cell和view提供layout attribute对象, 如果你也可给你的layout提供其他特性来提高用户体验的话, 这样你的layout对象更加健壮, 这些特性是可选的, 但推荐你提供这特性.
通过supplementary视图来将突出内容
supplementary视图是独立, 它和cell一样拥有自己的attribute, 由DataSource对象提供该view, 他们的作用是突出主体内容. 比如, UICollectionViewFlowLayout
使用supplementary视图作为section的header和footer. 有个的APP可能使用补充视图作为展示每个cell的信息label. 和cell一样, 补充视图也要循环使用, 所以补充视图是UICollectionReuseableView
的子类.
添加补充视图的步骤如下:
- 通过
registerClass:forSupplementaryViewOfKind:withReuseIdentifier:
或者registerNib:forSupplementaryViewOfKind:withReuseIdentifier:
来注册补充视图. - 在DataSource中, 实现
collectionView:viewForSupplementaryElementOfKind:atIndexPath:
, 并在使用dequeueReusableSupplementaryViewOfKind:withReuseIdentifier:forIndexPath:
来dequeue补充视图. - 给你的supplementary视图提供attribute对象
- 在
layoutAttributesForElementsInRect:
方法中, 将attribute放入数组中后将数组返回. - 使用
layoutAttributesForSupplementaryViewOfKind:atIndexPath:
方法为特定的supplementary视图提供attribute对象.
supplementary视图的创建过程和cell创建非常类似, 但有一点区别就是supplementary视图还包含各种类别, layout对象使用一个字符串来标识.
创建Decoration视图
Decoration视图是用来装饰collectionView中的内容的(cell), 和supplementary和cell不一样的地方是, 装饰视图由layout对象提供和DataSource无关, 仅用于内容显示. 装饰视图可以用来创建自定义背景, 填充cell的外围, 用于模糊cell. 装饰视图完全由layout对象控制和DataSource不会有任何交互.
下面是创建decoration视图的步骤:
-
registerClass:forDecorationViewOfKind:
或registerNib:forDecorationViewOfKind:
这两个方法用来注册装饰视图, 看起来和注册cell和补充视图一样, 不过你需要牢记的是, 这个两个方法需要在layout对象调用而不是DataSource中 - 在layout对象中的
layoutAttributesForElementsInRect:
方法中创建attribute对象后返回 - 实现
layoutAttributesForDecorationViewOfKind:atIndexPath:
方法, 为特定decoration视图提供attribute对象 - initialLayoutAttributesForAppearingDecorationElementOfKind:atIndexPath:和finalLayoutAttributesForDisappearingDecorationElementOfKind:atIndexPath:用来处理装饰视图的显示/隐藏动画的, 这两个方法的实现是可选的.
装饰视图的创建和cell/supplementary不同的. 创建时, 你要用UICollectionReusableView
来创建或者用nib来创建. 当需要装饰视图时, collectionView负责创建它, 然后使用layout提供的attribute信息来布局, 装饰视图是纯用来显示的, 不要用来做其他事, 而且装饰视图支持复用机制.
注意: 给装饰视图创建attribute时, 记得设置
zIndex
的值, 通过该属性可以将装饰置于cell/supplementary视图的前面/后面
创建插入/删除动画
cell的插入/删除操作会导致其他cell的布局变换, layout对象知道如何将collectionView中cell从初始位置(initial)动画移动到最终位置(final), 但不知道要插入的cell的初始位置和要删除的cell的最终位置, 所以做插入/删除动画时, 开发者需要告诉layout对象cell的初始位置/最终位置
图5-3展示了cell的插入动画的layout变换的示意图, collectionView的本来有三个cell, 插入一个cell, 它的初始位置在section的中心,并且alpha为0, layout将其从初始状态动画移动到最终位置(右下角,alpha为1).
下面的代码展示了如何在layout对象的-initialLayoutAttributesForAppearingItemAtIndexPath:
方法中提供item的初始attribute对象.
代码清单5-2 给插入的cell一个初始的attribute
- (UICollectionViewLayoutAttributes *)initialLayoutAttributesForAppearingItemAtIndexPath:(NSIndexPath *)itemIndexPath {
UICollectionViewLayoutAttributes* attributes = [self layoutAttributesForItemAtIndexPath:itemIndexPath];
attributes.alpha = 0.0;
CGSize size = [self collectionView].frame.size;
attributes.center = CGPointMake(size.width / 2.0, size.height / 2.0);
return attributes;
}
注意: 代码清单5-2中, 当一个cell插入时, 其他所有的cell都会做一个从中心做弹出动画. 为了只针对插入的cell做动画, 需要对cell的index path是否在prepareForCollectionViewUpdates:方法中传过来的items当中做判断, 如果在返回初始attribute, 如果不在则返回[super initialLayoutAttributesForAppearingItemAtIndexPath:indexPath]
cell的删除和cell的插入雷同, 不过你需要返回删除的final attribute, 才能做动画. UICollectionViewLayout
类提供了6个方法(cell/supplementary/decoration, 这个view分别对应的initial/final attribute)来返回相关的attribute
提高collectionView的滚动体验
自定义的layout对象可以改善collectionView的体验. 因为当滚动事件结束时, scrollView要根据当前的速度和减速度(负的加速度)来判断scrollView将要停在那里. 当collectionView知道了停留位置后, 会调用layout对象的targetContentOffsetForProposedContentOffset:withScrollingVelocity:方法, 来做微调. 因为在collectionView还在滚动的时候调用该方法, 所以你的自定义的layout可以影响滚动结束的最终位置.
图5-4展示了你将如何使用自定义layout对象去修改滚动行为. 如图所示, 通过targetContentOffsetForProposedContentOffset:withScrollingVelocity:
方法微调proposedContentOffset, 我们可以在结束滚动时将collectionView的内容置中, 这样可以提高用户体验.
关于自定义layout的几点建议
这里有几个关于实现自定义layout的提示和建议:
- 考虑使用
prepareLayout
方法去创建和保存UICollectionViewLayoutAttributes
对象.- collectionView会随机的请求attribute对象, 所以你事先创建和保存好一些attribute以备使用.
- 这种方式适合在item比较少(几百个)或者attribute对象不经常改变
- 如果你的item很多时(几千), 你就得权衡缓存和重新计算的利弊, 对于size可变的item且它们的layout不经常变, 所以缓存策略会减少layout的复杂计算量. 对于大量的固定size的item来说, layout计算相对来说比价简单, 而且如果layout老变得话, 需要反复计算layout, 所以缓存策略没有用处, 且浪费空间.
- 不要继承
UICollectionView
. collectionView本身很少或者没有外观上的显示, 相反, 它只是从DataSource中获取view和数据, 再从layout对象中获取布局信息, 将三者结合起来显示你想要展示的内容. 如果你想显示三维内容, 那么你可以自定义layout, 将3D变换加在cell上即可 - 在自定义layout时, 在
layoutAttributesForElementsInRect:
方法中不要向UICollectionView
发送visibleCells消息, 因为collectionView此时不知道各item的位置, 这些信息本来就是由layout像提供的, 你在layout的方法中去向collectionView请求visible cells, 最终还是要调用layout的方法来获取, 这里就是调用循环了. - item的位置信息只能layout负责, 所以计算item的位置信息时, 一般只能靠自己, 只有少数情况下会用到DataSource, 比如要将一些item画在地图上时, layout要根据DataSource来获取map的位置信息.