利用 diffable data source 顯示表格內容

從 iOS 13 開始,搭配全新的 UITableViewDiffableDataSource,現在從程式設定表格內容變得更方便了。

UITableViewDiffableDataSource 有很多厲害的地方,比方當表格呈現的資料內容有變化時,它可以比較差異,然後以生動的動畫顯示新增刪除的效果。(ps: diffable 是差異的意思,由名字可看出它是個擅長比較差異的狠角色)

接下來我們就以顯示蜘蛛人電影的表格為例, 一步步認識 UITableViewDiffableDataSource 吧。

以 UITableViewDiffableDataSource 實現的蜘蛛人電影表格

建立繼承 UITableViewController 的 MovieTableViewController

我們將在 MovieTableViewController 撰寫設定表格內容的程式。

class MovieTableViewController: UITableViewController {

}

在 storyboard 加入 table view controller,將 class 設為 MovieTableViewController

將 table view cell 的 Identifier 設為 movieCell

我們在程式裡要產生 storyboard 裡設計的 cell,因此我們將 cell 的 Identifier 設為 movieCell,之後才能從程式指名產生名叫 movieCell 的 cell。

設定 table view 的 row height

為了看清楚帥氣的蜘蛛人照片,我們將 cell 調大一點,高度設為 198。

table view 的 section & item

table view 顯示內容時,它會先以 section 分類,然後每個 section 底下再顯示東西(item)。比方通訊錄 App 會依字母分類 section,以下圖為例,P 是一個 section,section P 下的內容是名字 P 開頭的朋友,它顯示了大英雄 Peter Pan & Peter Park。而 T 是另一個 section,底下顯示可愛的 Tinker Bell。

在 MovieTableViewController 裡宣告型別 UITableViewDiffableDataSource 的 property dataSource

了解表格 section & item 的概念後,讓我們回過頭看看主角 class UITableViewDiffableDataSource。它的宣告如下,SectionIdentifierType, & ItemIdentifierType 是它的 generic type。

class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable

UITableViewDiffableDataSource 控制表格顯示的內容時,為了能在資料變化時辨識差異,知道哪些是新增刪除的資料,它需要我們設定 generic type,描述能辨識 section & item 的型別。因此我們在 MovieTableViewController 裡宣告型別 UITableViewDiffableDataSource 的 property dataSource 時,加上 <Section, Movie>說明它的 section 辨識型別是 Section,內容辨識型別是 Movie。

var dataSource: UITableViewDiffableDataSource<Section, Movie>!

以下為 Section & Movie 的定義。

enum Section {
case movie
}

struct Movie: Hashable {
var name: String
var actor: String
var year: Int
}

值得注意的,根據 UITableViewDiffableDataSource 的定義,section & item 的辨識型別必須遵從 protocol Hashable,因為這樣表格的 section & item 資料才能產生可判斷是否為同一筆資料的 hash value,UITableViewDiffableDataSource 將用 hash value 判斷資料的變化,然後再以生動的動畫呈現新增刪除的效果。

class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable

關於 protocol Hashable 的相關介紹可參考以下連結。

section 的辨識型別我們習慣用 enum 定義,因為表格的 section 的主要目的是分類,而 enum 的 case 很適合用來描述分類,因此表格 section 的數量即是 enum 裡 case 的數量。比方當電影列表 App 不分類時 ,enum 裡只需要一個 case movie。

enum Section {
case movie
}

但我們也可以設計包含多個 section 的電影列表,每個 section 代表不同類型的電影,比方分成喜劇,恐怖,動作片。

enum Section {
case comedy
case horror
case action
}

由於 enum 定義的型別本身就遵從 Hashable,所以定義 enum 時我們不用再加上 : Hashable。(ps: 搭配 associated value 的 enum 例外。)

而 item 的辨識型別我們習慣使用表格要顯示的資料型別,因此在這裡我們使用 struct 定義的型別 Movie。

struct Movie: Hashable {
var name: String
var actor: String
var year: Int
}

由於 Movie 的 property 也都遵從 Hashable,因此我們只要在 Movie 後接 : Hashable,Swift 將自動幫我們定義產生 hash value 的相關程式。不過若是 property 不遵從 Hashable,或是我們想自己實現產生 hash value 的程式,有時我們得自己定義 function hash(into:) & ==,相關資訊可參考以下連結。

宣告儲存蜘蛛人電影的 property movies

let movies = [
Movie(name: "蜘蛛人:返校日", actor: "湯姆", year: 2017),
Movie(name: "蜘蛛人:驚奇再起", actor: "安德魯", year: 2012),
Movie(name: "蜘蛛人", actor: "陶比", year: 2002)
]

在 viewDidLoad 裡利用 UITableViewDiffableDataSource 呈現表格內容

override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource<Section, Movie>(tableView: tableView) { tableView, indexPath, itemIdentifier in
let cell = tableView.dequeueReusableCell(withIdentifier: "movieCell", for: indexPath)
cell.textLabel?.text = itemIdentifier.name
cell.imageView?.image = UIImage(named: itemIdentifier.actor)
return cell
}
tableView.dataSource = dataSource
var snapshot = NSDiffableDataSourceSnapshot<Section, Movie>()
snapshot.appendSections([.movie])
snapshot.appendItems(movies)
dataSource.apply(snapshot, animatingDifferences: false)
}

說明

利用 UITableViewDiffableDataSource 呈現表格內容,主要可分成以下兩個重要的步驟。

產生 UITableViewDiffableDataSource & 設定 cell 內容

dataSource = UITableViewDiffableDataSource<Section, Movie>(tableView: tableView) { tableView, indexPath, itemIdentifier in
let cell = tableView.dequeueReusableCell(withIdentifier: "movieCell", for: indexPath)
cell.textLabel?.text = itemIdentifier.name
cell.imageView?.image = UIImage(named: itemIdentifier.actor)
return cell
}
tableView.dataSource = dataSource
  • 產生 UITableViewDiffableDataSource 物件

UITableViewDiffableDataSource 的 init 定義如下。

init(tableView: UITableView, cellProvider: @escaping UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType>.CellProvider)

型別 CellProvider 的定義如下。cellProvider 第三個參數的型別將等於我們建立 UITableViewDiffableDataSource 時指定的 ItemIdentifierType,因此在這裡它的型別是 Movie。

typealias CellProvider = (UITableView, IndexPath, ItemIdentifierType) -> UITableViewCell?

產生 UITableViewDiffableDataSource 物件時,重點在第二個參數 cellProvider 。

表格顯示時將呼叫 closure,得到要顯示的 cell。比方若表格要顯示蜘蛛人的三部電影,closure 將被呼叫三次,我們可由 closure 的第三個參數取得 cell 要顯示的電影,然後將它的內容設定到回傳的 cell 上。

  • 將 UITableViewDiffableDataSource 物件設為表格的 dataSource

當表格顯示內容時,它會找它的 property dataSource 幫忙。而 UITableViewDiffableDataSource 遵從了 UITableViewDataSource,因此我們可將 UITableViewDiffableDataSource 物件設為表格的 dataSource,交由 UITableViewDiffableDataSource 控制表格顯示的內容。

class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject, UITableViewDataSource where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable

此處有個特別的地方。有些聰明的朋友會想到何不直接將產生的 UITableViewDiffableDataSource 存入 tableView.dataSource ? 為何要麻煩地先在 controller 宣告 dataSource property,然後先存入 controller 的 dataSource,再存入 table view 的 dataSource 呢 ?

如上圖所示,倘若我們直接存入 table view 的 dataSource,將產生問題,Instance will be immediately deallocated because property dataSource is weak。

因為 UITableView 的 dataSource 是 weak,所以這樣存入的東西馬上就會死掉,可憐的 UITableViewDiffableDataSource,它還來不及顯示蜘蛛人就出師未捷身先死。

weak open var dataSource: UITableViewDataSource?

產生指定表格內容的 NSDiffableDataSourceSnapshot

var snapshot = NSDiffableDataSourceSnapshot<Section, Movie>()
snapshot.appendSections([.movie])
snapshot.appendItems(movies)
dataSource.apply(snapshot, animatingDifferences: false)

將 UITableViewDiffableDataSource 設為 table view 的 dataSource 後,表格還是一片空白,因為它還不知道要顯示什麼。我們還少了一個關鍵步驟,利用 NSDiffableDataSourceSnapshot 指定表格的內容。

let snapshot = NSDiffableDataSourceSnapshot<Section, Movie>()

產生決定表格內容的 NSDiffableDataSourceSnapshot。NSDiffableDataSourceSnapshot 的定義如下,SectionIdentifierType & ItemIdentifierType 是它的 generic type,它必須和當初 UITableViewDiffableDataSource 指定的 generic type 一致,因此我們傳入 Section & Movie

class NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType> where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable
snapshot.appendSections([.movie])

加入 section ,讓電影列表有一個 movie section。

snapshot.appendItems(movies)

加入 section 下的 items。在此我們傳入包含三部蜘蛛人電影的 array movies ,因此 section 下將顯示三個 cell。

dataSource.apply(snapshot, animatingDifferences: false)

從 UITableViewDiffableDataSource 物件呼叫 function apply(_:animatingDifferences:) 設定表格顯示的內容。

func apply(_ snapshot: NSDiffableDataSourceSnapshot<SectionIdentifierType, ItemIdentifierType>, animatingDifferences: Bool = true)

參數說明

snapshot: 表格顯示的內容。

animatingDifferences: 是否有動畫。

結果

UITableViewDiffableDataSource 搭配自訂 cell 類別

剛剛的表格採用的是內建的 cell 樣式,為了發揮我們的美術天份,顯示我們自己設計的表格,我們也可以搭配自訂的 cell 樣式。

產生 UITableViewDiffableDataSource 時,我們可在 cellProvider 的 closure 裡回傳自訂類別的 cell,如以下例子,我們利用 as! MovieTableViewCell 轉型。

dataSource = UITableViewDiffableDataSource<Section, Movie>(tableView: tableView) { tableView, indexPath, itemIdentifier in
let cell = tableView.dequeueReusableCell(withIdentifier: "movieCell", for: indexPath) as! MovieTableViewCell
cell.nameLabel.text = itemIdentifier.name
cell.yearLabel.text = itemIdentifier.year.description
cell.coverImageView.image = UIImage(named: itemIdentifier.actor)
return cell
}
class MovieTableViewCell: UITableViewCell {
@IBOutlet weak var yearLabel: UILabel!
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var coverImageView: UIImageView!

搭配多個 section 的 NSDiffableDataSourceSnapshot

電影列表時常會將電影分類,分成多個 section,因此接著讓我們試試顯示彼得潘最愛的兩個類型,愛情片 & 英雄片的電影列表。

首先我們在 enum Section 裡定義 2 個 case,romance & adventure。

enum Section {
case romance
case adventure
}

接著在 NSDiffableDataSourceSnapshot 加入 section & item 的內容。在這裡我們改呼叫 appendItems(_:toSection:),透過參數 sectionIdentifier 指定 items 對應的 section。

var snapshot = NSDiffableDataSourceSnapshot<Section, Movie>()
snapshot.appendSections([.romance, .adventure])
let romanceMovies = [Movie(name: "生命中的美好缺憾", actor: "雪琳", year: 2014), Movie(name: "真愛每一天", actor: "瑞秋", year: 2013), Movie(name: "手札情緣", actor: "雷恩", year: 2004)]
snapshot.appendItems(romanceMovies, toSection: .romance)
let adventureMovies = [Movie(name: "蜘蛛人:返校日", actor: "湯姆", year: 2017), Movie(name: "蜘蛛人:驚奇再起", actor: "安德魯", year: 2012),Movie(name: "蜘蛛人", actor: "陶比", year: 2002)]
snapshot.appendItems(adventureMovies, toSection: .adventure)
dataSource.apply(snapshot, animatingDifferences: false)

嫌剛剛的愛情片 & 英雄片連在一起,讓我們分不清手札情緣是戀人的手札還是蜘蛛人的手札嗎 ?

很簡單,我們只要將 table view 的 style 設為 Inset Grouped,即可讓 section 之間有明顯的區隔,每個 section 的內容被包覆在區塊裡。

取得點選 cell 的資料內容

為了讓電影 App 更完整,我們希望點選某個 cell 的電影後,跳到下一頁顯示它的詳細資訊,因此我們需要知道被點選 cell 對應的電影,然後將它傳到下一頁。

當表格的某個 cell 被點選時,我們可由 indexPathForSelectedRow 得知被選取的 IndexPath。因此我們可透過 UITableViewDiffableDataSource 的 function itemIdentifier(for:) 傳入 IndexPath,取得此位置對應的資料。

以下範例從 cell 拉 segue 到 MovieDetailViewController,我們在 MovieTableViewController 的 prepare 裡將電影傳給 MovieDetailViewController。

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let indexPath = tableView.indexPathForSelectedRow {
let controller = segue.destination as? MovieDetailViewController
controller?.movie = dataSource.itemIdentifier(for: indexPath)
}
}

完整範例參考

UICollectionViewDiffableDataSource

table view 的好兄弟 collection view 很喜歡模仿 table view,所以它也可以用類似的方法呈現內容。有興趣的朋友可進一步研究 UICollectionViewDiffableDataSource,使用的方式基本上跟 UITableViewDiffableDataSource 差不多。

以上即是 UITableViewDiffableDataSource 的基本介紹,當然它還有許多進階的應用,尤其在表格資料改變的時候。關於這些進階的操作,我們將在之後的文章再做說明。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com