IGListKit

本文首先会讲解如何使用 IGListKit,然后讲解 IGListKit 中最核心的两点,一是如何继承 UICollectionViewLayout 实现 UICollectionView 的自定义布局和动态变化的动画,二是如何从 Model 变化中计算出 IndexPath 变化,也就是 Diffing 算法,这样才好 batch updates。
基础使用
通过以下三篇资料就能够掌握基础的使用,这里主要讲解一些注意事项。
- Ray Wenderlich’s IGListKit Tutorial: Better UICollectionViews
- IGListKit Getting Started guide
- IGListKit Modeling and Binding
一个 Model 对应一个 Section 中一个 Cell
首先这种方式能够 Diffing 的最小单位是一个 UICollectionView 的 Section,所以一个 Model 数据对应一个 Section,在 UI 布局上,自然也可以给 Section 加上 Supplementary View,但是要注意 Supplementary View 和 Section 是一个整体,Diffing 动画中同时出现和消失。
ListSectionController
实现 ListAdapterDataSource 的 func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController 需要返回 ListSectionController。
class LabelSectionController: ListSectionController {
override func sizeForItem(at index: Int) -> CGSize {
return CGSize(width: collectionContext!.containerSize.width, height: 55)
}
override func cellForItem(at index: Int) -> UICollectionViewCell {
return collectionContext!.dequeueReusableCell(of: MyCell.self, for: self, at: index)
}
}
ListSingleSectionController
快速地实现单个 Cell 的 Section 很方便。
func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
let configureBlock = { (item: Any, cell: UICollectionViewCell) in
guard let cell = cell as? StoryboardCell, let number = item as? Int else { return }
cell.text = "Cell: \(number + 1)"
}
let sizeBlock = { (item: Any, context: ListCollectionContext?) -> CGSize in
guard let context = context else { return .zero }
return CGSize(width: context.containerSize.width, height: 44)
}
let sectionController = ListSingleSectionController(storyboardCellIdentifier: "cell",
configureBlock: configureBlock,
sizeBlock: sizeBlock)
sectionController.selectionDelegate = self
return sectionController
}
一个 Model 精细化地对应一个 Section 中多个 Cell
ListBindingSectionController
实现如下界面中 Model 到 Section 的映射很方便,一个 Section 中从上到下涵盖了 UserCell,ImageCell,ActionCell,CommentCell:
对于如下的一个 Post:
data.append(Post(
username: "@janedoe",
timestamp: "15min",
imageURL: URL(string: "https://placekitten.com/g/375/250")!,
likes: 384,
comments: [
Comment(username: "@ryan", text: "this is beautiful!"),
Comment(username: "@jsq", text: "😱"),
Comment(username: "@caitlin", text: "#blessed"),
]
))
将其拆解为精细的 ListDiffable 数据,可以看到和上面的 Cell 是一一对应的:
func sectionController(
_ sectionController: ListBindingSectionController<ListDiffable>,
viewModelsFor object: Any
) -> [ListDiffable] {
guard let object = object as? Post else { fatalError() }
let results: [ListDiffable] = [
UserViewModel(username: object.username, timestamp: object.timestamp),
ImageViewModel(url: object.imageURL),
ActionViewModel(likes: localLikes ?? object.likes)
]
return results + object.comments
}
以下是 GitHub 上的代码:
ListDiffable
实现 ListDiffable 的 Model 必须是 Class,diffIdentifier() 用来辨识不同的 Model,isEqual(toDiffableObject:) 用来辨识这个 Model 是否有变化,因为 Model 是 Class,属于引用类型,如果要实现更新某个 Model,然后刷新 UI,如果直接改动 Class 中的属性,ListDiffable 就检查不到变化,因为你的数据和 IGListKit 中的数据指向同一地址,必须要拷贝一次,再改动才能有变化,如果数据从网络获取,或者从本地数据库加载,就不存在此问题,只要是不同内存地址。
class CartAddress {
let receiver: String
let contactPhone: String
let detailAddress: String
init() {
receiver = ""
contactPhone = ""
detailAddress = ""
}
}
extension CartAddress: ListDiffable {
func diffIdentifier() -> NSObjectProtocol {
return "address" as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
if let object = object as? CartAddress {
return receiver == object.receiver
&& contactPhone == object.contactPhone
&& detailAddress == object.detailAddress
}
return false
}
}
如果想让 Swift structs diffable?尝试这个。
UICollectionViewLayout
UICollectionViewFlowLayout 并没有什么有趣的变化动画,要实现一些有趣的变化动画,就需要继承 UICollectionViewFlowLayout 或者 UICollectionViewLayout 来实现,通过以下四篇文章来学习:
- UICollectionView Custom Layout Tutorial: A Spinning Wheel
- UICollectionView Custom Layout Tutorial: Pinterest
- Custom UICollectionViewLayout Tutorial With Parallax
- Animating Items in a UICollectionView
以下是 GitHub 上的代码:
- danjiang / CollectionViewItemAnimations
- danjiang / CircularCollectionView
- danjiang / Pinterest
- danjiang / JungleCup
继承 UICollectionViewLayout 来实现基本的变化
override func prepare()
UICollectionView 第一次显示在屏幕上或者调用 UICollectionViewLayout 的 invalidateLayout() 就会调用 prepare()。
override var collectionViewContentSize: CGSize
然后 UICollectionView 需要通过上面的方法来决定内容的整体大小,不只是可见的部分。
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
这两个方法会在 UICollectionView 的布局过程中被频繁调用,layoutAttributesForElements(in rect: CGRect) 需要根据 rect 来返回在其中的 layout attributes。
override class var layoutAttributesClass: Swift.AnyClass
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes)
如果在给 UICollectionViewLayout 通过上面第一个方法提供了自定义的 layout attributes,UICollectionViewCell 需要重写上面第二个方法来应对 layout attributes 中自定义的属性,比如 anchorPoint。
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool
如果需要根据 bounds 的变化来重新计算 layout,上面方法需要返回 true。
接下来才是实现有趣的动态变化
触发 UICollectionView 的 Sections 或 Items 新增,删除,刷新和移动:
collectionView.insertSections(IndexSet(integer: 0))
collectionView.insertItems(at: [IndexPath(item: 0, section: 0)])
collectionView.deleteSections(IndexSet(integer: 0))
collectionView.deleteItems(at: [IndexPath(item: 0, section: 0)])
collectionView.reloadSections(IndexSet(integer: 0))
collectionView.reloadItems(at: [IndexPath(item: 0, section: 0)])
collectionView.moveSection(0, toSection: 1)
collectionView.moveItem(at: IndexPath(item: 0, section: 0),
to: IndexPath(item: 1, section: 0))
要批量处理就需要放到 performBatchUpdates() 的块中:
collectionView.performBatchUpdates({
// all kinds of changes
}) { finished in
// finished block
}
当前 batch of updates 的各项变化可以从 updateItems 中获取:
- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems
计算新增,删除,移动的 Item 的 layout attribute:
- (UICollectionViewLayoutAttributes*)initialLayoutAttributesForAppearingItemAtIndexPath:(NSIndexPath *)itemIndexPath
- (UICollectionViewLayoutAttributes*)finalLayoutAttributesForDisappearingItemAtIndexPath:(NSIndexPath *)itemIndexPath
计算新增,删除,移动的 Supplementary Element 的 layout attribute:
- (UICollectionViewLayoutAttributes*)initialLayoutAttributesForAppearingSupplementaryElementOfKind:(NSString *)elementKind atIndexPath:(NSIndexPath *)elementIndexPath
- (UICollectionViewLayoutAttributes*)finalLayoutAttributesForDisappearingSupplementaryElementOfKind:(NSString *)elementKind atIndexPath:(NSIndexPath *)elementIndexPath
确认所有的 layout attributes 变化:
- (void)finalizeCollectionViewUpdates
Diffing
从数据变化中计算出 IndexPath 变化,如果你不能计算出 IndexPath 变化,只能通过 reloadData 来整体刷新,效率低下,也不能让用户看到感知变化的动画,不是很友好。
通过以下二篇资料来了解一下 Diffing 算法,主要是 Wagner–Fischer 算法和 IGListKit 采用的 Heckel 算法:
以下是 GitHub 上的代码:
DeepDiff 使用也比较方便,自定义的 Model 需要实现 Hashable:
let oldItems = items
items = DataSet.generateNewItems()
let changes = diff(old: oldItems, new: items)
collectionView.reload(changes: changes, section: 2, completion: { _ in })
tableView.reload(changes: changes, completion: { _ in })