QuickDataSource 1.0.0

QuickDataSource 1.0.0

Tomasz Gebarowski 维护。



QuickDataSource

为 UITableView 和 UICollectionView 编写可测试的数据源和 ViewModels 的 μFramework。它促进了关注点的分离,并鼓励编写更少的样板代码。

特性

相同的数据源适用于 UITableView 和 UICollectionView。

支持单个 Table 或 Collection View 中的不同单元格类型。

模型、视图和逻辑分离。

更少的样板代码。

支持平铺、嵌套和聚合(例如,由日期驱动的)数据源。

与协调器模式和 ViewModels 集成良好。

要求

  • 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,唯一的不同之处在于它提供了一个可以连接聚合函数的入口点。

将操作与单元格选择连接起来

几乎在处理 UITableViewUICollectionView 的任何情况下,我们希望在单元格被选中时添加一些交互。为了这个目的,可以使用我们的 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 继承,并修改任何存在的特定单元格逻辑。这不是很简单吗?

从单元格触发操作

并非所有单元格都像前面所描述的那样简单。有时它们包含按钮、文本框或从单元格范围之外唤起特定的逻辑。为此,可以使用单元格装饰器,将从外部上下文注入依赖。

假设我们有一个包含 UIButtonGridCell。按下该按钮会触发一个发送到我们之前定义的协调器的操作。

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)对本项目的初始反馈。