在 iOS 13 中,Apple 除了引入了 Swift UI 這個宣告式 (declarative) UI 框架外,還為 UIKit 框架添加了不少新功能,當中最重要的就是 UICollectionView
的改善。
準確來說,新的 Compositional Layouts 和 Diffable Data Sources APIs,讓我們更容易構建進階 CollectionView
佈局和集中的資料源。
iOS 14 更進一步,帶來了新的 Cell registration API,並在 UICollectionView
內為 UITableView
提供開箱即用的支援。
但更重要的是,現在 iOS 14 的 Diffable data sources 新增了 section snapshot,讓你可以以一個 Section 為基礎更新 Data。在構建 iOS 14 推出的新層次設計的 Outline Styled 列表,這個功能十分有用。
今年,Diffable data source 的另一個新功能是 First-class reordering。
目標
- 快速重溫 Diffable data source
- 了解如何實作 section snapshots
- 深入探究新的 reordering API
快速重溫 Diffable data source
在這個新的宣告式 API 推出前,開發者需要使用 numberOfItemsInSection
和cellForItemAt
方法,來建立資料源;而要更新資料,就要使用 performBatchUpdates()
和 reloadData()
方法。
這個方法雖然可以建立和更新 data,但就會導致資料源分散。更壞的是,reloadData()
方法會讓我們無法展示漂亮的動畫,而 performBatchUpdates
就會無意地導致一些常見錯誤,例如 NSInternalInconsistencyException
。
有了新的 Diffable data source,我們可以透過 Snapshot 提供資料,以獲得集中的資料源。
Snapshot 代表一種資料的狀態,不是依賴 index path 來更新 item,而是依靠型式安全 (type-safe) 的唯一識別符 (identifier),來識別唯一的 Section 和 Item。
更好的是,你可以使用 apply
方法,在 NSDiffableDataSourceSnapshot
實例中設定為 UITableView
或 UICollectionView
的資料源,並讓其處理動畫。有趣的是,apply
方法也可以從後台線程執行。
總括來說,Diffable data source 可以計算差異,並讓我們可以在 UICollectionView
和 UITableView
佈局中更輕鬆地管理資料源。您可以存取 dataSource.snapshot()
來讀取 UI 元件的當前狀態,並相應地添加或刪除 item。
iOS 14 引入的 SectionSnapshots
在 iOS 14 之前,要在 iOS 13 填充 item 和 section,我們需要在NSDiffableDataSourceSnapshot
使用以下的方法:
var snapshot = NSDiffableDataSourceSnapshot<String, String>()
snapshot.appendSections(["1", "2"])
如果要在一個 Section 添加 Item,我們就會使用這個方法:
snapshot.appendItems(["1.1"], toSection: "1")
snapshot.appendItems(["2.1"], toSection: "2")
那麼,既然我們已經可以以一個 Section 為基礎添加 Item,新的 NSDiffableDataSourceSectionSnapshot
API 又可以為列表帶來甚麼呢?
簡單來說,就是客製化大綱列表 (outlined list) 或展開式列表視圖 (expandable list)。
我們可以利用 NSDiffableDataSourceSectionSnapshot
API,輕鬆地建立及更新展開式集合視圖,讓視圖可以展開及折疊某些 Section。如此一來,我們就可以方便地建立階層式 (hierarchical) 資料。
以下是 iOS 14 新的 Section Snapshot 提供的方法:
現在,讓我們利用全新的 Section Snapshot 來提供資料源,以建立一個 iOS 14 CollectionView
。
建立你的資料模型
我們的資料源會保存字串 (string) 的階層式資料。因此,讓我們使用 childItems
陣列創建兩個 item 的結構:
struct Child : Hashable{
let item: String
}
struct Parent: Hashable {
let item: String
let childItems: [Child]
}
因為 parent item (標題 (header))和 childItems
都是字串,所以我們需要一個方法,為它們會進入的 UICollectionViewCell 區分兩者的型別。讓我們為它們創建 case 列舉 (enum):
enum OutlineItem: Hashable {
case parent(Parent)
case child(Child)
}
現在我們已經準備好了資料模型,以下是這次用到的虛擬資料,我們將會用這些資料來填充 UICollectionView
:
建立我們的 Diffable data source
有了新的 iOS 14 Cell 註冊技巧,要初始化 UICollectionViewCell
,我們不再需要使用傳統的 Cell identifier 方法。
我們可以如此在 iOS 14 UICollectionView.CellRegistration
建立和顯示內容,並傳遞到 UICollectionViewDiffableDataSource
。
func makeDataSource() -> UICollectionViewDiffableDataSource<String, OutlineItem> {
let parentRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Parent> { cell, indexPath, item in
var content = cell.defaultContentConfiguration()
content.text = item.item
cell.contentConfiguration = content
let headerDisclosureOption = UICellAccessory.OutlineDisclosureOptions(style: .header)
cell.accessories = [.outlineDisclosure(options:headerDisclosureOption)]
}
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Child> { cell, indexPath, item in
var content = cell.defaultContentConfiguration()
content.text = item.item
cell.indentationLevel = 2
cell.contentConfiguration = content
}
return UICollectionViewDiffableDataSource<String, OutlineItem>(
collectionView: collectionView,
cellProvider: { collectionView, indexPath, item in
switch item{
case .parent(let parentItem):
let cell = collectionView.dequeueConfiguredReusableCell(
using: parentRegistration,
for: indexPath,
item: parentItem)
return cell
case .child(let childItem):
let cell = collectionView.dequeueConfiguredReusableCell(
using: cellRegistration,
for: indexPath,
item: childItem)
return cell
}
})
}
我們已經註冊了兩個 Cell,一個是每個 Section 的 Root,並包含 Disclosure Indicator;另一個則是用來顯示每個 child item 的內容。
現在我們的資料源已經準備好了,讓我們在 CollectionView
上設定它吧!
private lazy var dataSource = makeDataSource()
最後,我們把 Snapshot 套用到以上的資料源。
建立 section snapshots
我們可以如此構建一個 section snapshot:
collectionView.dataSource = dataSource
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<OutlineItem>()
for data in hirerachicalData{
let header = OutlineItem.parent(data)
sectionSnapshot.append([header])
sectionSnapshot.append(data.childItems.map { OutlineItem.child($0) }, to: header)
sectionSnapshot.expand([header])
}
dataSource.apply(sectionSnapshot, to: "Root", animatingDifferences: false, completion: nil)
我們迭代了 hierarchical
資料,並將父實例設置為每個 Section 的標題,並在其中設置 childItems
。此外,我們還展開了每個標題的 Section,以顯示所有項目(你可以配置為僅隱藏/展開特定 Section)。
最後,把 Section Snapshot 應用於 UICollectionView
的 Root Section。在模擬器上運行時,App 看起來會是這樣:
如果想要 UICollectionView + diffable data source 和 Section Snapshot 的完整源程式碼,可以到 GitHub 參考。
你也可以在 dataSource
上設置 sectionSnapshotHandlers
,來客製化各個 Section Item 的展開狀態。SectionSnapshotHandler<Item>
提供了不同的閉包,例如 shouldCollapseItem
、willCollapseItem
、willExpandItem
、和 ShouldExpandItem
。
使用新的 Reordering API
Section Snapshop 不但可以讓我們構建展開式列表,並確定 item 的嵌套級別 (nested level)(列表的 root),它還有一個 Reordering API,可以快速地插入到我們的 diffable data source 中。
具體來說,要啟用重新排序,我們需要定義以下兩個閉包:
然後,你需要如此設置附件 (accessory) 來註冊 Cell:
cell.accessories = [.reorder(displayed:.always)]
請注意,為簡便起見,我們將重新排序圖標設置為總是顯示 (always be displayed)。但是,我建議你設置一個 Edit
按鈕,以便在 whenEditing
和 whenNotEditing
狀態之間切換,以啟用/停用重新排序。
didReorder
和 willReorder
閉包會傳遞一個 NSDiffableDataSourceTranscation
新型別。
Transaction 會包含所有更新 diffable data source 所需的資訊:
CollectionDifference
是 Swift 5.1 引入的一個新型別,描述了兩個 Collection State 之間 item 的插入和刪除。
因此,你可以在 didReorder
閉包內,按已重新排序的 transcation 來簡單地更新原來的資料源:
originalDataSource.applying(transaction.difference)
總結
這篇文章說明了 iOS 14 Diffable data sources 的轉變。你可以利用 Section Snapshot 和新的重新排序 API,輕鬆地在 CollectionViews
建立和更新大量資料。
謝謝你的閱讀。