比 UICollectionView更好用的IGListKit教程

原文:IGListKit Tutorial: Better UICollectionViews
作者:Ryan Nystrom
译者:kmyhy

每个 app 都以同样的方式开始:几个界面,几颗按钮,一两个 list。但随着进度的进行以及 app 膨胀,功能开始发生变化。你简单的数据源开始在工期和产品经理的压力下变得支离破碎。再过一久,你留下一堆庞大得难以维护的 view controller。今天,IGListKit 来拯救你了!

IGListKit 专门用于解决在使用 UICollectionView 时出现的功能蔓延(需求蔓延)和 view controller 膨胀的问题。用 IGListKit 创建列表,你可以使用非耦合组件来构建 app,飞快的刷新,支持任何类型的数据。

本教程中,你将重构一个 UICollectionView 成 IGListKit,然后扩展 app,让它超凡脱俗!

开始

如果你是 NASA 的顶尖软件工程师,正在从事最新的火星载人飞行任务。开发团队已经做好了第一版的 MarsLink app,你可以在[这里](https://koenig-media.raywenderlich.com/uploads/2016/12/Marslink_Starter.zip)下载。下载完这个项目,打开 Marslink.xcworkspace 并运行 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。

展开阅读全文

没有更多推荐了,返回首页