比 UICollectionView更好用的IGListKit教程
每個 app 都以同樣的方式開始:幾個介面,幾顆按鈕,一兩個 list。但隨著進度的進行以及 app 膨脹,功能開始發生變化。你簡單的資料來源開始在工期和產品經理的壓力下變得支離破碎。再過一久,你留下一堆龐大得難以維護的 view controller。今天,IGListKit 來拯救你了!
IGListKit 專門用於解決在使用 UICollectionView 時出現的功能蔓延(需求蔓延)和 view controller 膨脹的問題。用 IGListKit 建立列表,你可以使用非耦合元件來構建 app,飛快的重新整理,支援任何型別的資料。
本教程中,你將重構一個 UICollectionView 成 IGListKit,然後擴充套件 app,讓它超凡脫俗!
開始
這個 app 簡單地列出了宇航員的飛行日誌。
你的任務是當團隊需要新功能時,新增新功能給這個 app。開啟 Marslink\ViewControllers\ClassicFeedViewController.swift 隨便看看,熟悉一下專案。如果你用過 UICollectionView,你會發現它非常普通:
- ClassicFeedViewController 繼承了 UIViewController ,並用一個擴充套件實現 了 UICollectionViewDataSource 協議。
- viewDidLoad() 中建立了一個 UICollectionView, 註冊了 cell,設定了資料來源,然後將它新增到檢視樹中。
- loader.entries 陣列儲存了幾個 section,每個 section 中有兩個 cell(一個日期,一個文字)。
- 日期 cell 顯示陽曆的日期,文字 cell 顯示日誌內容。
- collectionView(_:layout:sizeForItemAt:) 方法返回一個固定的大小用於日期 cell,以及一個根據字串大小計算出來的 size 給文字 cell。
每件事情都很完美,但是專案 leader 帶來了一個緊急的產品升級需求:
在火星上,一名宇航員擱淺了。我們需要新增一個天氣預報模組和實時聊天。你只有48小時的時間。
JPL(噴氣推進實驗室,Jet Propulsion Laboratory) 的工程師要用到這些功能,但需要你將他們放到這個 app 中來。
如果把一名宇航員帶回家的壓力還不夠大的話,NASA 的首席設計師還有一個需求,app 中每個子系統的升級必須是的平滑的,也就是不能 reloadData()。
你怎麼會以為將這些模組整合到一個偉大的 app 中並建立所有的轉換動畫?這名宇航員已經沒有多少土豆了!
IGListKit 介紹
UICollectionView 是一個極其強大的工具,與其強大一起的是負有同樣大的責任。保持你的資料來源和檢視同步是極其重要的,通常崩潰就是因為這裡沒搞好。
IGListKit 是一個數據驅動的 UICollectionView 框架,有 Instagram 團隊編寫。使用這個框架,你提供一個物件陣列用於顯示到 UICollectionView 中。對於每種型別的物件,需要建立一個 adapter,也叫做 section controller,裡面包含了所有建立 cell 所需要的細節。
IGListKit 自動識別你的物件並在任何東西發生變化時在 UICollectonView 上執行批量動畫重新整理。這樣,你就永遠不需要編寫 batch update 語句,避免這裡列出的警告。
將 UICollectionView 換成 IGListKit
IGListKit 負責所有識別 collection 中發生變化,並以動畫方式重新整理對應的行。它還能夠輕易處理針對不同的 section 使用不同的 data 和 UI 的情況。考慮到這一點,它能夠完美解決當前需求——讓我們開始吧!
在 Marslink.xcworkspace 開啟的情況下,右擊 ViewControllers 資料夾並選擇 New File…。新建一個 Cocoa Touch Class 繼承於 UIViewController 並命名為 FeedViewController。
開啟 AppDelegate.swift 找到 application(_:didFinishLaunchingWithOptions:) 方法。找到將ClassicFeedViewController() push 到 navigation controller 的行,將它換成:
nav.pushViewController(FeedViewController(), animated: false)
FeedViewController 現在成為了 root view controller。你可以保留 ClassicFeedViewController.swift 作為參考,但 FeedViewController 將作為你使用 IGListKit 實現一個 collection view 的地方。
執行程式,確保你能看到一個嶄新的、空白的 view controller shows。
新增 Journal loader
開啟 FeedViewController.swift 在 FeedViewController 頂部新增屬性:
let loader = JournalEntryLoader()
JournalEntryLoader 是一個類,用於載入一個硬編碼的日誌記錄到一個數組中。
在 viewDidLoad() 最後一行新增:
loader.loadLatest()
loadLatest() 是 JournalEntryLoader 中的方法,載入最新的日誌記錄。
加入 collection view
現在來新增某些 IGListKit 的特殊控制元件到 view controller 中了。在這樣做之前,你需要引入這個框架。在 FeedViewController.swift 頂部加入 import 語句:
import IGListKit
注意:本示例專案使用 CocoaPods 管理依賴。IGListKit 是 Objective-C 些的,因此需要在橋接標頭檔案中用 #import 手動新增到你的專案。
在 FeedViewController 頂部新增一個 collectionView 常量:
// 1
let collectionView: IGListCollectionView = {
// 2
let view = IGListCollectionView(frame: CGRect.zero, collectionViewLayout: UICollectionViewFlowLayout())
// 3
view.backgroundColor = UIColor.black
return view
}()
- IGListKit 使用了 IGListCollectionView, 這是一個 UICollectionView 的子類,添加了某些功能並修復了某些缺陷。
- 一開始用一個大小為 0 的 frame,因為 view 都還沒建立。它也使用了 UICollectionViewFlowLayout ,就像 ClassicFeedViewController 一樣。
- 背景色設為 NASA-認可的黑色。
在 viewDidLoad() 方法最後一句加入:
view.addSubview(collectionView)
這將新的 collectionView 新增到 controller 的 view。
在 viewDidLoad() 下面加入:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView.frame = view.bounds
}
viewDidLayoutSubviews() 是一個覆蓋方法,將 collectionView的 frame 設為view 的 bounds。
IGListAdapter 和資料來源
使用 UICollectionView,你需要某個資料來源實現 UICollectionViewDataSource 協議。它的作用是返回 section 和 row 的數目以及每個 cell。
在 IGListKit 中,你使用一個 GListAdapter 來控制 collection view。你仍然需要一個數據源來實現 IGListAdapterDataSource 協議,但不是返回數字或 cell,你需要提供陣列和 controllers(後面會細講)。
首先,在 FeedViewController.swift 在頭部加入:
lazy var adapter: IGListAdapter = {
return IGListAdapter(updater: IGListAdapterUpdater(), viewController: self, workingRangeSize: 0)
}()
這建立了一個延遲載入的 IGListAdapter 變數。這個初始化方法有 3 個引數:
- updater 是一個實現了 IGListUpdatingDelegate 協議的物件, 它負責處理 row 和 section 的重新整理。IGListAdapterUpdater 有一個預設實現,剛好給我們用。
- viewController 是一個 UIViewController ,它擁有這個 adapter。 這個 view controller 後面會用於導航到別的 view controllers。
- workingRangeSize 是 warking range 的大小。允許你為那些不在可見範圍內的 section 準備內容。
注意:working range 是另一個高階主題,本教程不會涉及。但是在 IGListKit 的程式碼庫中有它豐富的文件甚至一個示例 app。
在 viewDidLoad() 方法最後一行新增:
adapter.collectionView = collectionView
adapter.dataSource = self
這會將 collectionView 和 adapter 聯絡在一起。還將 self 設定為 adapter 的資料來源——這會報一個錯誤,因為你還沒有實現 IGListAdapterDataSource 協議。
要解決這個錯誤,宣告一個 FeedViewController 擴充套件以實現 IGListAdapterDataSource 協議。在檔案最後新增:
extension FeedViewController: IGListAdapterDataSource {
// 1
func objects(for listAdapter: IGListAdapter) -> [IGListDiffable] {
return loader.entries
}
// 2
func listAdapter(_ listAdapter: IGListAdapter, sectionControllerFor object: Any) -> IGListSectionController {
return IGListSectionController()
}
// 3
func emptyView(for listAdapter: IGListAdapter) -> UIView? { return nil }
}
FeedViewController 現在採用了 IGListAdapterDataSource 協議並實現了 3 個必須的方法:
- objects(for:) 返回一個數據物件組成的陣列,這些物件將顯示在 collection view。這裡返回了loader.entries,因為它包含了日誌記錄。
- 對於每個資料物件,listAdapter(_:sectionControllerFor:) 方法必須返回一個新的 section conroller 例項。現在,你返回了一個空的 IGListSectionController以解除編譯器的抱怨——等會,你會修改這裡,返回一個自定義的日誌的 section controller。
- emptyView(for:) 返回一個 view,它將在 List 為空時顯示。NASA 給的時間比較倉促,他們沒有為這個功能做預算。
建立第一個 Section Controller
一個 section controller 是一個抽象的物件,指定一個數據物件,它負責配置和管理 CollectionView 中的一個 section 中的 cell。這個概念類似於一個用於配置一個 view 的 view-model:資料物件就是 view-model,而 cell 則是 view,section controller 則是二者之間的粘合劑。
在 IGListKit 中,你根據不同型別的資料的型別和特性建立不同的 section controller。JPL 的工程師已經放入了一個 JournalEntry model,你只需要建立能夠處理這個 Model 的 section controller 就行了。
在 SectionController 資料夾上右擊,選擇 New File…,建立一個 Cocoa Touch Class 名為 JournalSectionController ,繼承 IGListSectionController。
Xcode 不會自動引入第三方框架,因此在 JournalSectionController.swift 需要新增:
import IGListKit
為 JournalSectionController 新增如下屬性:
var entry: JournalEntry!
let solFormatter = SolFormatter()
JournalEntry 是一個 model 類,在實現資料來源時你會用到它。SolFormatter 類提供了將日期轉換為太陽曆格式的方法。很快你會用到它們。
在 JournalSectionController 中,覆蓋 init() 方法:
override init() {
super.init()
inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
}
如果不這樣做,section 中的 cell 會一個緊挨著一個。這個方法在每個 JournalSectionController 物件的下方增加 15 個畫素的間距。
你的 section controller需要實現 IGListSectionType 協議才能被 IGListKit 所用。在檔案最後新增一個擴充套件:
extension JournalSectionController: IGListSectionType {
func numberOfItems() -> Int {
return 2
}
func sizeForItem(at index: Int) -> CGSize {
return .zero
}
func cellForItem(at index: Int) -> UICollectionViewCell {
return UICollectionViewCell()
}
func didUpdate(to object: Any) {
}
func didSelectItem(at index: Int) {}
}
注意: IGListKit 非常依賴 required 協議方法。但在這些方法中你可以空實現,或者返回 nil,以免收到“缺少方法”的警告或執行時報錯。這樣,在使用 IGListKit 時就不容易出錯。
你實現了 IGListSectionType 協議的 4 個 required 方法。
所有方法都是無存根的實現,除了 numberOfItems() 方法— 返回了一個 2 ,表示一個日期和一個文字字串。你可以回到 ClassicFeedViewController.swift 看看,在collectionView( _:numberOfItemsInSection:) 方法中你返回的也是 2。這兩個方法基本上是一樣的。
在 didUpdate(to:)方法中加入:
entry = object as? JournalEntry
didUpdate(to:) 用於將一個物件傳給 section controller。注意在任何 cell 協議方法之前呼叫。這裡,你把接收到的 object 引數賦給 entry。
注意:在一個 section controller 的生命週期中,物件有可能會被改變多次。這隻會在啟用了 IGListKit 的更高階的特性時候發生,比如自定義模型的 Diffing 演算法。在本教程中你不需要擔心 Diffing。
現在你有一些資料了,你可以開始配置你的 cell 了。將 cellForItem(at:) 方法替換為:
// 1
let cellClass: AnyClass = index == 0 ? JournalEntryDateCell.self : JournalEntryCell.self
// 2
let cell = collectionContext!.dequeueReusableCell(of: cellClass, for: self, at: index)
// 3
if let cell = cell as? JournalEntryDateCell {
cell.label.text = "SOL \(solFormatter.sols(fromDate: entry.date))"
} else if let cell = cell as? JournalEntryCell {
cell.label.text = entry.text
}
return cell
cellForItem(at:) 方法詢問到 section 的某個 cell(指定的 Index)時呼叫。以上程式碼解釋如下:
- 如果 index 是第一個,返回 JournalEntryDateCell 單元格,否則返回 JournalEntryCell 單元格。日誌資料總是先顯示日期,然後才是文字。
- 從快取中取出一個 cell,dequeue 時需要指定 cell 的型別,一個 section controller 物件,以及 index。
- 根據 cell 的型別,用你先前在 didUpdate(to objectd:)方法中設定的 entry 來配置 cell。
然後,將 sizeForItem(at:) 方法替換為:
// 1
guard let context = collectionContext, let entry = entry else { return .zero }
// 2
let width = context.containerSize.width
// 3
if index == 0 {
return CGSize(width: width, height: 30)
} else {
return JournalEntryCell.cellSize(width: width, text: entry.text)
}
collectionContext 是一個弱引用,同時是 nullabel 的。雖然它永遠不可能為空,但最好是做一個前置條件判斷,使用 Swift 的 guard 語句就行了。
IGListCollectionContext 是一個上下文物件,儲存了這個 section view 中用到的 adapter、collecton view、以及 view controller。這裡我們需要獲取容器 container 的寬度。
如果是第一個 index(即日期 cell),返回一個寬度等於 container 寬度,高度等 30 畫素的 size。否則,使用 cell 的助手方法根據 cell 文字計算 size。
最後一個方法是 didSelectItem(at:),這個方法在點選某個 cell 時呼叫。這是一個 required 方法,你必須實現它,但如果你不想進行任何處理的話,可以空實現。
這種 dequeue 不同型別的 cell、對 cell 進行不同配置和並返回不同 size 的套路和你之前使用 UICollectionView 的套路並無不同。你可以回去 ClassicFeedViewController 看看,這些程式碼中有許多都很相似!
現在你擁有了一個 section controller,它接收一個 JournalEntry 物件,並返回連個 cell 和 size。接下來我們就來使用它。
開啟 FeedViewController.swift, 將 listAdapter(_:sectionControllerFor:) 方法替換為:
return JournalSectionController()
現在,這個方法返回了新的 Journal Section Controller 物件。
執行程式,你將看到一個航空日誌的列表!
新增訊息
JPL 工程師很高興你能這麼快就完成了修改,但他們還需要和那個倒黴的宇航員建立聯絡。他們要你儘快將訊息模組也整合進去。
在新增任何檢視之前,首先的一件事情就是資料。
開啟 FeedViewController.swift 新增一個屬性:
let pathfinder = Pathfinder()
PathFinder() 扮演了訊息系統,並代表了火星上宇航員的探路車。
在 IGListAdapterDataSource 擴充套件中找到 objects(for:) ,將內容修改為:
var items: [IGListDiffable] = pathfinder.messages
items += loader.entries as [IGListDiffable]
return items
你可能想起來了,這個方法負責將資料來源物件提供給 IGListAdapter。這裡進行了一些修改,將 pathfinder.messages 新增到 items 中,以便為新的 section controller 提供訊息資料。
注意:你必須轉換訊息陣列以免編譯器報錯。這些物件已經實現了 IGListDiffable 協議。
在 SectionControllers 資料夾上右擊,建立一個新的 IGListSectionController 子類名為 MessageSectionController。在檔案頭部引入 IGListKit:
import IGListKit
讓編譯器不報錯之後,保持剩下的內容不變。
回到 FeedViewController.swift 修改 IGListAdapterDataSource 擴充套件中的 listAdapter(_:sectionControllerFor:) 方法為:
if object is Message {
return MessageSectionController()
} else {
return JournalSectionController()
}
現在,如果資料物件的型別是 Message,,我們會返回一個新的 Message Secdtion Controller。
JPL 團隊需要你在建立 MessageSectionController 時滿足下列需求:
- 接收 Message 訊息
- 底部間距 15 畫素
- 通過 MessageCell.cellSize(width:text:) 函式返回一個 cell 的 size
- dequeue 並配置一個 MessageCell,並用 Message 物件的 text 和 user.name 屬性填充 Label。
試試看!如果你需要幫助,JPL 團隊也在下面的提供了參考答案:
答案: MessageSectionController
import IGListKit
class MessageSectionController: IGListSectionController {
var message: Message!
override init() {
super.init()
inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
}
}
extension MessageSectionController: IGListSectionType {
func numberOfItems() -> Int {
return 1
}
func sizeForItem(at index: Int) -> CGSize {
guard let context = collectionContext else { return .zero }
return MessageCell.cellSize(width: context.containerSize.width, text: message.text)
}
func cellForItem(at index: Int) -> UICollectionViewCell {
let cell = collectionContext?.dequeueReusableCell(of: MessageCell.self, for: self, at: index) as! MessageCell
cell.messageLabel.text = message.text
cell.titleLabel.text = message.user.name.uppercased()
return cell
}
func didUpdate(to object: Any) {
message = object as? Message
}
func didSelectItem(at index: Int) {}
}
當你寫完時,執行 app,看看將訊息整合後的效果!
火星天氣預報
我們的宇航員需要知道當前天氣以便避開某些東西比如沙塵暴。JPL 編寫了一個顯示當前天氣的模組。但是那個資訊有點多,因此他們要求只有在使用者點選之後才顯示天氣資訊。
編寫最後一個 sectioncontroller,名為 WeatherSecdtionController。現在這個類中定義一個建構函式和幾個變數:
import IGListKit
class WeatherSectionController: IGListSectionController {
// 1
var weather: Weather!
// 2
var expanded = false
override init() {
super.init()
// 3
inset = UIEdgeInsets(top: 0, left: 0, bottom: 15, right: 0)
}
}
這個 section controller 會從 didUpdate(to:) 方法中接收到一個 Weather 物件。
expanded 是一個布林值,用於儲存天氣 section 是否被展開。預設為 false,這樣它下面的 cell 一開始是摺疊的。
和另外幾個 section 一樣,底部 inset 設定為 15 畫素。
加一個 IGListSectionType 擴充套件,實現 3 個 required 方法:
extension WeatherSectionController: IGListSectionType {
// 1
func didUpdate(to object: Any) {
weather = object as? Weather
}
// 2
func numberOfItems() -> Int {
return expanded ? 5 : 1
}
// 3
func sizeForItem(at index: Int) -> CGSize {
guard let context = collectionContext else { return .zero }
let width = context.containerSize.width
if index == 0 {
return CGSize(width: width, height: 70)
} else {
return CGSize(width: width, height: 40)
}
}
}
- 在 didUpdate(to:) 方法中,你儲存了傳入的 Weather 物件。
- 如果天氣被展開,numberOfItems() 返回 5 個 cell,這樣它會包含天氣資料的每個部分。如果不是展開狀態,只返回一個用於顯示佔位內容的 cell。
- 第一個 cell 會比其他 cell 大一點,因為它是一個 Header。沒有必要判斷展開狀態,因為 Header cell 只會顯示在第一個 cell。
然後你需要實現 cellForItem(at:)方法來配置 weather cell。有幾個細節需要注意:
- 第一個 cell 是 WeatherSummaryCell 型別,其他 cell 是 WeatherDetailCell 型別。
- 通過 cell.setExpanded(_:) 方法來配置 WeatherSummaryCell。
配置 4 個不同的 WeatherDetailCell 用下列 title 和 detail 標籤:
- “Sunrise” - weather.sunrise
- “Sunset” - weather.sunset
- “High” - “(weather.high) C”
- “Low” - “(weather.low) C”
試著配置一下這個 cell! 參考答案如下。
func cellForItem(at index: Int) -> UICollectionViewCell {
let cellClass: AnyClass = index == 0 ? WeatherSummaryCell.self : WeatherDetailCell.self
let cell = collectionContext!.dequeueReusableCell(of: cellClass, for: self, at: index)
if let cell = cell as? WeatherSummaryCell {
cell.setExpanded(expanded)
} else if let cell = cell as? WeatherDetailCell {
let title: String, detail: String
switch index {
case 1:
title = "SUNRISE"
detail = weather.sunrise
case 2:
title = "SUNSET"
detail = weather.sunset
case 3:
title = "HIGH"
detail = "\(weather.high) C"
case 4:
title = "LOW"
detail = "\(weather.low) C"
default:
title = "n/a"
detail = "n/a"
}
cell.titleLabel.text = title
cell.detailLabel.text = detail
}
return cell
}
最後還有最後一件事情,當 cell 被點選時,切換 section 的展開狀態並重新整理 cell。在 IGListSectionType 擴充套件後實現這個 required 協議方法:
func didSelectItem(at index: Int) {
expanded = !expanded
collectionContext?.reload(self)
}
reload() 方法重新載入整個 section。當 section controller 中的 cell 的數目或者內容發生變化時,你可以呼叫這個方法。因此我們通過 numberOfItems() 方法切換 section 的展開狀態,在這個方法中根據 expanded 的值來新增或減少 cell 的數目。
回到 FeedViewController.swift, 在頭部加入屬性:
let wxScanner = WxScanner()
WxScanner 是一個用於天氣情況的模型物件。
然後,修改 IGListAdapterDataSource 擴充套件中的 objects(for:) 方法:
// 1
var items: [IGListDiffable] = [wxScanner.currentWeather]
items += loader.entries as [IGListDiffable]
items += pathfinder.messages as [IGListDiffable]
// 2
return items.sorted(by: { (left: Any, right: Any) -> Bool in
if let left = left as? DateSortable, let right = right as? DateSortable {
return left.date > right.date
}
return false
})
我們修改了資料來源方法,讓它增加 currentWeather 的資料。程式碼解釋如下:
- 將 currentWeather 新增到 items 陣列。
- 讓所有資料實現 DataSortable 協議,以便用於排序。這樣資料會按照日期前後順序排列。
最後,修改 listAdapter(_:sectionControllerFor:) 方法:
if object is Message {
return MessageSectionController()
} else if object is Weather {
return WeatherSectionController()
} else {
return JournalSectionController()
}
現在,當 object 是 Weather 型別時,返回一個 WeatherSectionController。
執行 app。你會在頂部看到新的天氣物件。點選這個 section,展開和收起它!
更新操作
JPL 對你的進度相當的滿意!當你在工作時,NASA 的 director 組織了對宇航員的營救工作,要求他起飛並攔截另一艘飛船!這是一次複雜的起飛,他起飛的時間必須十分精確。
JPL 工程師擴充套件了訊息模組,加入了實時聊天功能,要求你整合它。
開啟 FeedViewController.swift 在 viewDidLoad() 方法最後一行加入:
pathfinder.delegate = self
pathfinder.connect()
這個 Pathfinder 模組增加了實時聊天支援。你需要做的僅僅是連線這個模組並處理委託事件。
在檔案底部增加新的擴充套件:
extension FeedViewController: PathfinderDelegate {
func pathfinderDidUpdateMessages(pathfinder: Pathfinder) {
adapter.performUpdates(animated: true)
}
}
FeedViewController 現在實現了 PathfinderDelegate 協議。只有一個 performUpdates(animated:completion:) 方法,用於告訴 IGListAdapter 查詢資料來源中的新物件並重新整理UI。這個方法用於處理物件被刪除、更新、移動或插入的情況。
執行 app,你會看到標題上訊息正在重新整理!你只不過是為 IGListKit 添加了一個方法,用於說明資料來源發生了什麼變化,並在收到新資料時執行修改動畫。
現在,你所需要做的僅僅是將最新版本發給宇航員,他就能回家了!幹得不錯!
結束
在這裡下載最後完成的專案。
在幫助一位擱淺的宇航員回家的同時,你學習了 IGListKit 的基本功能:section controller、adapter、以及如何將它們組合在一起。還有其他重要的功能,比如 supplementary view 和 display 事件。
你可以閱讀 Instagram 放在 Realm 上關於為什麼要編寫 IGListKit 的討論。這個討論中提到了許多在編寫 app 時經常遇到在 UICollecitonView 中出現的問題。
如果你對參加 IGListKit 有興趣,開發團隊為了便於讓你開始,在 Github 上建立了一個 starter-task 的tag。