QuickDataSource
为 UITableView 和 UICollectionView 编写可测试的数据源和 ViewModels 的 μFramework。它促进了关注点的分离,并鼓励编写更少的样板代码。
特性
要求
- iOS 10.0+
- Xcode 10.1+
- Swift 4.2+
安装
CocoaPods
CocoaPods 是一个 Cocoa 项目的依赖管理器。要使用 CocoaPods 将 QuickDataSource 集成到您的 Xcode 项目中,请在您的 Podfile
中指定以下内容
pod 'QuickDataSource', '~> 1.0.0'
Carthage
Carthage 是一个去中心化的依赖管理工具,它可以为您构建依赖项并为您提供二进制框架。要使用 Carthage 将 QuickDataSource 集成到您的 Xcode 项目中,请在您的 Cartfile
中指定它
github "tgebarowski/QuickDataSource" "1.0.0"
快速教程
Flat list
实现一个无分区头部的扁平单元格列表(支持不同类型的单元格)。
第1步(创建单元格)
创建表示要显示内容的 UITableViewCell。
class DummyCell: UITableViewCell {
@IBOutlet fileprivate weak var label: UILabel!
}
通过遵循 CellLoadableType,我们提供了一个 load(viewModel:) 方法,将 ViewModel 数据与 @IBOutlets 属性绑定
extension DummyCell: CellLoadableType {
func load(viewModel: DummyViewModel) {
label.text = viewModel.label
}
}
第2步(创建 ViewModel)
struct DummyViewModel: Equatable, Hashable {
let label: String
}
通过实现ItemType协议,我们提供了一个cellToViewModelBinder()方法,它返回一个单元格与ViewModel之间的绑定。
extension DummyViewModel: ItemType {
func cellToViewModelBinder() -> CellToViewModelBinderType {
return CellToViewModelBinder<DummyCell>(viewModel: self)
}
}
第3步(视图控制器)
当ViewModel、单元格和它们之间的绑定都已定义后,我们可以创建一个负责显示内容的视图控制器。假设我们有一个静态的单元格列表。我们可以通过传递一个DummyViewModels列表来创建一个FlatDataSource,并将其包裹在TableViewDataSource中,分配给它到tableView.dataSource属性。采用这种方法,我们无需实现UITableViewDataSource协议,而且几乎可以无缝地将我们的FlatDataSource作为UICollectionViewData源使用,只需将它包裹在CollectionViewDataSource中。
class FlatDataSourceViewController: UIViewController {
@IBOutlet private weak var tableView: UITableView!
lazy var staticDataSource: DataSourceType = {
return FlatDataSource(items: [DummyViewModel(label: "Foo"),
DummyViewModel(label: "Bar"),
DummyViewModel(label: "Tar")])
}()
lazy var tableDataSource: TableViewDataSource = {
return TableViewDataSource(items: self.staticDataSource)
}()
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = tableDataSource
}
}
注意,我们必须对tableDataSource对象创建一个强引用,因为tableView.dataSource没有保留引用。在源代码中出现的懒惰变量用于缩短代码,但在生产版本的代码中,请考虑使用依赖注入。
FlatDataSource不会限制我们只能使用一个ViewModel类型,我们可以创建各种ViewModel并将它们映射到不同的单元格
struct DateViewModel: Equatable, Hashable {
let date: String
}
extension DummyViewModel: ItemType {
func cellToViewModelBinder() -> CellToViewModelBinderType {
return CellToViewModelBinder<DateCell>(viewModel: self)
}
}
class DateCell: UITableViewCell {
@IBOutlet fileprivate weak var dateLabel: UILabel!
}
extension DummyCell: CellLoadableType {
func load(viewModel: DateViewModel) {
dateLabel.text = viewModel.date
}
}
并像这样使用它们
lazy var staticDataSource: DataSourceType = {
return FlatDataSource(items: [DummyViewModel(label: "Foo"),
DateViewModel(date: "7th of May"),
DummyViewModel(label: "Tar")])
}()
请注意,这里我们假设单元格在Storyboard中定义,因为我们不需要注册它们,但这种方法也可以用于从代码中编写的单元格布局或使用XIBs。
嵌套数据源
有时候一个简单的单元格列表不够用。为此,我创建了一个NestedDataSource,它允许我们将数据分为由相应标题描述的列表。
我们可以通过定义对应的视图和ViewModel来引入这样的标题。
class DummyHeaderView: UITableViewHeaderFooterView {
fileprivate let label = UILabel()
...
}
extension DummyHeaderView: CellLoadableType {
func load(viewModel: DummyHeaderViewModel) {
label.text = viewModel.title
label.textColor = viewModel.textColor
}
}
struct DummyHeaderViewModel: Hashable, Equatable {
let title: String
let textColor: UIColor
init(title: String, textColor: UIColor = .white) {
self.title = title
self.textColor = textColor
}
}
extension DummyHeaderViewModel: ItemType {
func cellToViewModelBinder() -> CellToViewModelBinderType {
return CellToViewModelBinder<DummyHeaderView>(viewModel: self)
}
}
其次,我们添加对Comparable的支持,这样DummyHeaderViewModel就能知道如何在列表中排序自身。
extension DummyHeaderViewModel: Comparable {
static func < (lhs: DummyHeaderViewModel, rhs: DummyHeaderViewModel) -> Bool {
return lhs.title < rhs.title
}
}
作为下一步,我们可以准备一个由一个字典组成的NestedDataSource,这个字典将DummyHeaderViewModel表示的部分映射到一个DummyViewModel数组。请注意,只要我们的数组项目符合ItemType,我们就可以在数组中混合不同类型的单元格。
lazy var staticHeaderDataSource: DataSourceType = {
let dict: [DummyHeaderViewModel: [ItemType]] = [DummyHeaderViewModel(title: "Header 1", textColor: .white):
[DummyViewModel(label: "Foo"), DummyViewModel(label: "Bar")],
DummyHeaderViewModel(title: "Header 2", textColor: .white):
[DummyViewModel(label: "Baz"), DummyViewModel(label: "Qux")]]
return NestedDataSource<DummyHeaderViewModel>(items: dict, sectionsComparator: >)
}()
lazy var tableDataSource: TableViewDataSource = {
return TableViewDataSource(items: self.staticHeaderDataSource)
}()
在这里,我们使用了一个自定义的分区比较器,它由在 DummyHeaderViewModel 扩展中定义的小于运算符(<)实现。
由于UITableView标题是由UITableViewDelegate(而不是UITableViewDataSource)返回的,因此我们必须构建一个标题,并为它提供数据源项目。
lazy var tableDelegate: UITableViewDelegate = {
return TableViewDelegate(tableViewDataSource: self.tableViewDataSource)
}()
最后,当所有这些步骤都完成时,我们可以将UITableView属性分配给我们的包装器。
tableView.dataSource = self.tableDataSource
tableView.delegate = self.tableViewDelegate
聚合数据源
假设我们有一个模型,它表示一些订单,并包含订单的日期。
struct Order {
let date: Date
let name: String
}
我们希望按天对订单进行分组。我们可以使用 AggregatedDataSource 而不需要写太多代码。我们首先必须做的事是为我们的模型添加对 Aggregating 协议的遵守
extension Order: Aggregating {
var aggregator: String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter.string(from: date)
}
}
该协议定义了一个单独的计算属性(aggregator),它提供了我们所要聚合的内容的字符串表示形式。
请注意,构建一个 DateFormatter 是一个昂贵的操作,因此在生产就绪的代码中应该考虑到这一点。为了简单起见,让我们假设这是可以接受的。
完成这个之后,我们可以创建一个静态的订单列表
let orders = [Order(date: Date(), name: "Foo"),
Order(date: Date(), name: "Bar"),
Order(date: Date(), name: "Baz"),
Order(date: Date().addingTimeInterval(3600 * 24 * 7), name: "Qux")]
现在,让我们创建一个函数,将这个列表转换为字典,以日期作为 DummyHeaderViewModel 键,并且具有代表包装订单的 DummyViewModel 列表作为字典值
let aggregator: ([Order]) -> [DummyHeaderViewModel: [DummyViewModel]] = { (items) in
return Dictionary(grouping: items) { return DummyHeaderViewModel(title: $0.aggregator) }
.mapValues { $0.map { DummyViewModel(label: $0.name ) } }
}
完成这个之后,我们可以创建一个 AggregatedDataSource
AggregatedDataSource<Order, DummyHeaderViewModel, DummyViewModel>(items: orders,
aggregator: aggregator,
sectionsComparator: <))
AggregatedDataSource 非常类似于 NestedDataSource,唯一的不同之处在于它提供了一个可以连接聚合函数的入口点。
将操作与单元格选择连接起来
几乎在处理 UITableView 或 UICollectionView 的任何情况下,我们希望在单元格被选中时添加一些交互。为了这个目的,可以使用我们的 TableViewDelegate 并提供一个处理单元格选择的函数。
lazy var tableDelegate: TableViewDelegate = {
return TableViewDelegate(tableViewDataSource: self.tableDataSource,
cellSelectionHandler: self.actionHandler)
}()
actionHandler() 函数可以将操作转发到我们自己的 ViewModel(遵守 ItemType)。通过引入可能遵守该协定的 ActionHandler 协议,我们能够将从 ViewModel 到 ActionCoordinator 中相应方法调用的调用进行转发(注意这里使用了访问者模式)
private func actionHandler(indexPath: IndexPath, item: ItemType?) {
(item as? ActionsHandler)?.accept(visitor: coordinator)
}
protocol ActionsHandler {
func accept(visitor: ActionsCoordinator)
}
class ActionsCoordinator {
private let viewController: UIViewController
init(viewController: UIViewController) {
self.viewController = viewController
}
func handle(item: DummyViewModel) {
let alert = UIAlertController(title: "Alert", message: "DummyViewModel: \(item.label)",
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
viewController.present(alert, animated: true, completion: nil)
}
}
extension DummyViewModel: ActionsHandler {
func accept(visitor: ActionsCoordinator) { visitor.handle(item: self) }
}
当然,我们可以通过在 ActionsCoordinator 中添加新的 handle(item:) 方法来支持更多的单元,并为其他 ViewModel 添加对 ActionsHandler 的遵守。就像这样
extension DateViewModel: ActionsHandler {
func accept(visitor: ActionsCoordinator) { visitor.handle(item: self) }
}
extension ActionsCoordinator {
func handle(item: DateViewModel) {
...
}
}
支持 UICollectionView
当我们决定丢弃对 UITableView 的支持时,我们可以轻松地将代码迁移到使用 UICollectionView。不会改变的是数据源本身,唯一的不同之处在于我们将把它包裹在一个由 QuickDataSource 框架提供的 CollectionViewDataSource 中。
lazy var dataSource: DataSourceType = {
return FlatDataSource(items: [DummyViewModel(label: "Foo"),
DummyViewModel(label: "Bar"),
DummyViewModel(label: "Baz"),
DummyViewModel(label: "Qux"),
DummyViewModel(label: "Yam"),
DummyViewModel(label: "Xam"),
DummyViewModel(label: "Zum")])
}()
lazy var collectionDataSource: CollectionViewDataSource = {
return CollectionViewDataSource(items: self.dataSource)
}()
lazy var collectionDelegate: CollectionViewDelegate = {
return CollectionViewDelegate(dataSource: self.dataSource,
cellConfigurator: self.cellConfigurator)
}()
override func viewDidLoad() {
super.viewDidLoad()
...
collectionView.dataSource = collectionDataSource
collectionView.delegate = collectionDelegate
}
最后,我们将必须将 DummyCell 更改为从 UICollectionViewCell 继承,并修改任何存在的特定单元格逻辑。这不是很简单吗?
从单元格触发操作
并非所有单元格都像前面所描述的那样简单。有时它们包含按钮、文本框或从单元格范围之外唤起特定的逻辑。为此,可以使用单元格装饰器,将从外部上下文注入依赖。
假设我们有一个包含 UIButton 的 GridCell。按下该按钮会触发一个发送到我们之前定义的协调器的操作。
class GridCell: UICollectionViewCell {
@IBOutlet fileprivate weak var button: UIButton!
fileprivate var viewModel: GridViewModel?
weak var coordinator: ActionsCoordinator?
@IBAction func buttonTapped(sender: UIButton) {
guard let viewModel = viewModel else { return }
self.coordinator?.handle(item: viewModel)
}
}
extension GridCell: CellLoadableType {
func load(viewModel: GridViewModel) {
self.viewModel = viewModel
button.setTitle(viewModel.title, for: .normal)
}
}
可以通过在 cellConfigurator 函数中注入或直接将闭包传递给 CollectionViewDelegate 的方式来连接协调器。
class GridDataSourceViewController: UIViewController {
...
lazy var coordinator = {
return ActionsCoordinator(viewController: self)
}()
...
lazy var collectionDelegate: CollectionViewDelegate = {
return CollectionViewDelegate(dataSource: self.dataSource,
cellConfigurator: self.cellConfigurator)
}()
private func cellConfigurator(cell: UICollectionViewCell) {
(cell as? GridCell)?.coordinator = self.coordinator
}
override func viewDidLoad() {
super.viewDidLoad()
...
collectionView.delegate = collectionDelegate
}
}
作为最终步骤,我们可以扩展 ActionsCoordinator 以支持添加到 GridCell 中的 UIButton 触发的事件。
extension GridViewModel: ActionsHandler {
func accept(visitor: ActionsCoordinator) { visitor.handle(item: self) }
}
extension ActionsCoordinator {
func handle(item: GridViewModel) {
...
}
}
示例
更多详情请见 示例字典。
作者
塔马斯·热巴罗夫斯基。Twitter: @tgebarowski
鸣谢
非常感谢 @arekholko 及其 ConfigurableTableViewController 的启发和一些关键概念:https://github.com/fastred/ConfigurableTableViewController
我要感谢 Pawel Kowalczuk(@riamf1)对本项目的初始反馈。