1. 程式人生 > >比 UICollectionView更好用的IGListKit教程

比 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
}()
  1. IGListKit 使用了 IGListCollectionView, 這是一個 UICollectionView 的子類,添加了某些功能並修復了某些缺陷。
  2. 一開始用一個大小為 0 的 frame,因為 view 都還沒建立。它也使用了 UICollectionViewFlowLayout ,就像 ClassicFeedViewController 一樣。
  3. 背景色設為 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 個引數:

  1. updater 是一個實現了 IGListUpdatingDelegate 協議的物件, 它負責處理 row 和 section 的重新整理。IGListAdapterUpdater 有一個預設實現,剛好給我們用。
  2. viewController 是一個 UIViewController ,它擁有這個 adapter。 這個 view controller 後面會用於導航到別的 view controllers。
  3. 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 個必須的方法:

  1. objects(for:) 返回一個數據物件組成的陣列,這些物件將顯示在 collection view。這裡返回了loader.entries,因為它包含了日誌記錄。
  2. 對於每個資料物件,listAdapter(_:sectionControllerFor:) 方法必須返回一個新的 section conroller 例項。現在,你返回了一個空的 IGListSectionController以解除編譯器的抱怨——等會,你會修改這裡,返回一個自定義的日誌的 section controller。
  3. 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)時呼叫。以上程式碼解釋如下:

  1. 如果 index 是第一個,返回 JournalEntryDateCell 單元格,否則返回 JournalEntryCell 單元格。日誌資料總是先顯示日期,然後才是文字。
  2. 從快取中取出一個 cell,dequeue 時需要指定 cell 的型別,一個 section controller 物件,以及 index。
  3. 根據 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)
    }
  }
}
  1. 在 didUpdate(to:) 方法中,你儲存了傳入的 Weather 物件。
  2. 如果天氣被展開,numberOfItems() 返回 5 個 cell,這樣它會包含天氣資料的每個部分。如果不是展開狀態,只返回一個用於顯示佔位內容的 cell。
  3. 第一個 cell 會比其他 cell 大一點,因為它是一個 Header。沒有必要判斷展開狀態,因為 Header cell 只會顯示在第一個 cell。

然後你需要實現 cellForItem(at:)方法來配置 weather cell。有幾個細節需要注意:

  • 第一個 cell 是 WeatherSummaryCell 型別,其他 cell 是 WeatherDetailCell 型別。
  • 通過 cell.setExpanded(_:) 方法來配置 WeatherSummaryCell。
  • 配置 4 個不同的 WeatherDetailCell 用下列 title 和 detail 標籤:

    1. “Sunrise” - weather.sunrise
    2. “Sunset” - weather.sunset
    3. “High” - “(weather.high) C”
    4. “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 的資料。程式碼解釋如下:

  1. 將 currentWeather 新增到 items 陣列。
  2. 讓所有資料實現 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。