Owl为在iOS中构建快速且灵活的列表提供了一种数据驱动的声明式方法。
它支持UICollectionView
和UITableView
;UIStackView
也在开发中!
特性亮点 | |
---|---|
不再需要委托和dataSource。只需完全类型安全的声明式内容方法 | |
更好的架构,可以重用组件并从UI中十倍数据 | |
自动动画内容更改,无需reloadData /performBatchUpdates |
|
基于DifferenceKit的闪电般快速的差异算法 | |
它在其核心使用标准的UIKit组件。没有魔法! | |
(即将推出)支持可滚动声明式/全可定制的StackView | |
完全由Swift开发,由Swift爱好者编写的 |
Owl由Daniele Margutti创建并维护 - 我的个人网站www.danielemargutti.com。
要求
- Xcode 9.0+
- iOS 10.0+
- Swift 5+
安装
首选的安装方法是使用CocoaPods。将以下内容添加到您的Podfile
中
pod 'OwlKit'
您还可以使用Carthage安装Owl。将此内容添加到您的Cartfile
中
github "malcommac/Owl"
Owl也兼容SPM (Swift Package Manager) 用于Swift 5.x+
您将获得
这是如何使用Owl实现功能齐全的Contacts列表的示例。这是一个简单的示例,但您可以轻松创建具有异构模型的复杂布局!
director = TableDirector(table: table)
// An adapter encapsulate the logic to render a model (Contact) with a specific ui (ContactCell).
let contactAdapter = TableCellAdapter<Contact,ContactCell> { dr in
// Each adapter exposes all the standard methods of a list, events
// and properties. ctx (context) received from event is a type-safe
// object both for model and cell!
dr.events.dequeue = { ctx in
// element is type-safe, it's the Contact instance!
// cell is type-safe, it's the ContactCell instance
ctx.cell?.item = ctx.element
}
dr.events.didSelect = { ctx in
let vc = ContactDetailVC(people: ctx.element)
navigationController.pushViewController(vc, animated: true)
}
dr.events.shouldHighlight = { ctx in
return ctx.element.isBlacklisted == false
}
}
// Since now our table can show Contact istances using ContactCell
// All events are received only in its adapter.
director?.registerCellAdapter(contactAdapter)
/// Manage your content in a declarative way!
let friendsSection = TableSection(elements: [john,mark,anita])
director?.add(section: friendsSection)
director?.reload()
许可证
- Owl使用Apache 2.0许可证发布。
- DifferenceKit使用Apache 2.0许可证发布。
- Owl图标由Freepik创作,来源www.flaticon.com,遵循CC 3.0 BY许可
文档
- 1 - 介绍:导演 & 适配器
- 2 - 启动
- 3 - 如何操作
- 4 - API文档:管理
UITableView
- 5 - API文档:管理
UICollectionView
- 6 - 监听
UIScrollViewDelegate
事件
1.0 - 介绍:导演 & 适配器
Owl的所有SDK都基于两个概念:导演(director)和适配器(adapter)。
导演
导演是管理列表内容的类,保持数据与UI同步,并提供所有方法和属性来管理它。当您需要添加、移动或删除一个节或单元格,更改头部或尾部时,您将在该类中找到所有方法和属性。一个导演实例只能与一个列表(表或集合)相关联;一旦导演分配给列表,它就成为该对象的数据源和委托。
以下导演可用
TableDirector
用于管理UITableView
实例CollectionDirector
和FlowCollectionDirector
用于管理带有自定义布局或UICollectionViewFlowLayout
布局的UICollectionView
。
适配器
为列表创建新的导演后,您需要声明列表可以接受哪些类型的模型。每个模型都分配给一个UI元素(表格使用UITableViewCell
子类,集合使用UICollectionViewCell
子类)。
适配器的范围是声明导演可以管理的模型和UI的一对。
整个框架都基于这个概念:一个模型可以由一个单个UI元素渲染,而选择哪个UI来为特定的模型实例则由Owl来决定。
适配器也是接收涉及特定模型实例的事件的中心点。例如:当用户触摸类型A的模型实例的行时,您将接收事件(以及相关信息:索引路径,涉及到的模型实例等),并在管理该模型的适配器中接收这些信息。
您将注册尽可能多的适配器,与您拥有的模型数量一样多。
2.0 - 入门
以下代码展示如何创建一个导演来管理 UITableView
(对于 UICollectionView
使用了类似的方法)。
public class MyController: UIViewController {
@IBOutlet public var table: UITableView!
private var director: TableDirector?
func viewDidLoad() {
super.viewDidLoad()
director = TableDirector(table: table)
// ...
现在是时候声明要由我们的导演管理的类型的内容了。为了简单起见,我们将只声明一个适配器,但您可以声明您需要的适配器数量(您也可以创建包含适配器的自己的导演并按需重用它。这是一个很好的重用和分离数据的方法)。
func viewDidLoad() {
// ...
let contactAdpt = TableCellAdapter<Contact, ContactCell>()
// You can attach events for adapter configuration.
// The following example show how to set the row height and dequeue
contactAdpt.events.rowHeight = { ctx in
return 60.0 // explicit row height
}
contactAdpt.events.dequeue = { ctx in
// this is the suggested behaviour; your cell should expose a
// property of the type of the model it can be render and you will
// assign it on dequeue. It's type safe too!!
ctx.cell?.contact = ctx.element
}
director?.registerCellAdapter(contactAdpt)
这是一个使用我们的导演使用 ContactCell
细胞渲染类型为 Contact
的对象的最小设置。您可以通过适配器的 .events
属性配置和附加大量其他属性和事件;以下是每个属性的更详细描述,但所有 UITableView
/UICollectionView
的事件和属性都受支持。
现在是在我们的表格中添加一些内容的时候了。正如我们所说的,Owl 使用命令式方法来处理内容:这意味着您可以通过使用命令式函数(如 add
、remove
、move
)来设置列表的内容,这两个函数适用于分节和元素。
// You can add/remove/move elements at anytime in a declarative way
let contacts = [
Contact(first: "John", last: "Doe"),
Contact(first: "Adam", last: "Best"),
...
]
// ...
director?.add(elements: contacts)
director?.remove(section: 1)
有大量方法来管理分节和分节内的元素。
director?.firstSection?.removeAt(2) // remove an element
director?.remove(section: 2) // remove section
director?.section("mySection").move(swappingAt: 0, with:1) // swap elements
director?.add(elements: [...], at: 5) // add new elements
// and more...
一旦您更改了数据模型,您可以调用 reload()
函数来将其与UI同步。
以下代码创建(自动)一个新的包含 contacts
元素的 TableSection
。如果您需要,您可以手动创建一个新的分节。
// Create a new section explicitly. Each section has an unique id you can assign
// explicitly or leave Owl create an UUID for you. It's used for diff features.
let newSection = TableSection(id: "mySectionId", elements: contacts, header: "\(contacts) Contacts", footer: nil)
director?.add(section: newSection)
为了同步UI,只需调用 director?.reload()
,就这样!只需几行代码就创建了一个完整功能的列表!
您可以在任何时候通过使用 TableSection
/CollectionSection
或 TableDirector
/CollectionDirector
实例中的方法来添加新分节,移动或替换项目,然后调用 reload()
来刷新内容。
刷新操作没有动画效果,但Owl also能自动根据极快的差异算法选择最佳动画来刷新数据内容,详细说明见下一章。
以下代码演示了如何为一个适配器添加滑动删除功能
catalogAdapter.events.canEditRow = { ctx in
return true
}
catalogAdapter.events.deleteConfirmTitle = { ctx in
return "Delete"
}
catalogAdapter.events.commitEdit = { [weak self] ctx, style in
guard let indexPath = ctx.indexPath else { return }
// By using the session to reload you will also receive events like didEndEditingCell
self?.tableDirector?.reload(afterUpdate: { dir in
dir.sections[indexPath.section].remove(at: indexPath.row)
return .none
}, completion: nil)
}
3.0 - 如何操作
3.1 - 创建模型
模型可以是有结构或类;唯一的要求是它需要符合ElementRepresentable
协议。这个协议被差异算法使用,以便评估应用模型的变化差异并选择最佳的动画来展示。
这意味着你不需要执行任何performBatches
更新,也不需要手动调用reloadRows/removeRows/addRows
方法……Owl都自动为你处理这些繁琐且容易崩溃的操作。
以下代码演示了我们的Contact
模型声明
public class Contact: ElementRepresentable {
let firstName: String
let lastName: String
public var differenceIdentifier: String {
return "\(self.firstName)_\(self.lastName)"
}
public func isContentEqual(to other: Differentiable) -> Bool {
guard let other = other as? Contact else { return false }
return other == self
}
init(first: String, last: String) {
self.firstName = first
self.lastName = last
}
}
通过添加以下方式来实现协议的一致性:
differenceIdentifier
属性:为了区分添加或删除的模型,模型需要唯一标识(这是添加uuid
属性的理想位置)isContentEqual(to:)
函数:用于检查是否有任何属性发生变化,用于替换更改。如果模型在重新加载之间数据发生变化,Owl会相应地更新单元格的UI。
3.2 - 创建单元格
第二步是创建模型的UI表示。通常是UITableViewCell
或UICollectionViewCell
的子类。
重用标识符
单元格的reuseIdentifier
值应该是其类的本身名称(例如,ContactCell
有ContactCell
作为标识符;也可以根据需要配置,但这是一个好习惯)。
最佳实践
您不需要遵循任何特殊的协议,但为了保持代码的整洁,我们建议创建一个公共属性,它接受模型实例并将其设置在适配器的 dequeue
事件上。
public class ContactCell: UITableViewCell {
// Your outlets
@IBOutlet public var ...
// Define a property you set on adapter's dequeue event
public var contact: Contact? {
didSet {
// setup your UI according with instance data
}
}
}
在您的适配器中
contactAdpt.events.dequeue = { ctx in
ctx.cell?.contact = ctx.element
}
ctx
是一个包括事件所需所有信息(包括模型类型安全的实例)的对象。
3.3 - 管理自适应大小的单元格
自适应单元格的配置简单,适用于表格和集合视图。
Owl 使用自动布局支持轻松设置单元格大小。您可以按适配器或集合/表格设置单元格大小。对于由自动布局驱动的单元格大小设置,将 rowHeight
(对于 TableDirector
)或 itemSize
(对于 CollectionDirector
/FlowCollectionDirector
)设置为自动布局值,然后提供一个估计值。
接受值包括
default
:您必须提供单元格的高度(表格)或大小(集合)auto(estimated: CGFloat)
:使用自动布局评估单元格的高度;对于集合视图,您还可以通过在单元格实例中覆盖preferredLayoutAttributesFitting()
函数来提供自己的计算。explicit(CGFloat)
:为所有单元格类型提供固定高度(如果您计划所有单元格大小相同,则更快)
3.4 - 从 Storyboard/Xib/Class 加载 Cells/View
此行为用于加载/回收表格和集合视图的单元格和头部/尾部
实体 | 默认可复用标识符 | 默认加载方法 |
---|---|---|
Cells(《UITableViewCell》,《UICollectionViewCell》) | 类名 | 从父表格的 Storyboard 单元格原型列表中使用可复用标识符加载 UI |
Header/Footer View(《UITableViewHeaderFooterView》,《UICollectionReusableView》) | 类名 | 尝试将从同一类本身同包含包含有同一名称的 xib 的束中加载视图作为根元素(例如,“GroupHeaderView.xib”) |
您可以通过在适配器(对于表格的单元格和头部/尾部为 TableAdapter
或 TableHeaderFooterAdapter
,对于集合的单元格和头部/尾部为 CollectionAdapter
/CollectionHeaderFooterAdapter
)之前设置以下属性来自定义此行为。
reusableViewIdentifier
:设置一个新的可重用标识符以使用。reusableViewLoadSource
:更改用于从队列中获取视图的源(对于单元格,默认值为.fromStoryboard
,对于标题/页脚是.fromXib(name:nil, bundle: nil)
)。允许的值有
默认情况下,单元格或标题/页脚将使用类的名称本身作为 reusableIdentifier
(例如,你的单元格 ContactCell
必须将标识符设置为 ContactCell
)。这是一个有用的约定,可以避免奇怪的标识符。
但是,您可以通过设置 cellReuseIdentifier
(在 TableAdapter
或 CollectionAdapter
中用于单元格)和 viewReuseIdentifier
(在 TableHeaderFooterAdapter
/CollectionHeaderFooterAdapter
中用于标题/页脚)来覆盖这种行为。
另一个有趣的属性是 reusableViewLoadSource
;这指定 Owl 需要在哪里搜索您即将加载的单元格/视图的 UI。允许的值有
fromStoryboard
:从表/集合原型列表中的故事板中加载(单元格的默认值)。fromXib(name: String?, bundle: Bundle?)
:从一个特定束包中的 xib 文件中加载(如果name
为 nil,则使用与单元格类相同的文件名,即ContactCell.xib
;如果bundle
为nil
,则使用与类相同的束包)。fromClass
:从类中加载。
以下示例从与类名称相同的外部 xib(例如,ContactCell.xib
)加载单元格,并包含在单元格类的相同束包中。相同的做法也适用于标题/页脚。
let contactAdpt = TableCellAdapter<Contact, ContactCell>()
// instead of load cell from storyboard it will be loaded by
// reading the root view inside the xib with the same name of the class
contactAdpt.reusableViewLoadSource = .fromXib(name:nil, bundle:nil)
// optionally you can also set a custom id
contactAdpt.reusableViewIdentifier = "CustomContactCellID"
// configure...
director?.registerCellAdapter(contactAdpt)
3.5 - 自定义部分标题/页脚
部分(无论是 TableSection
还是 CollectionSection
)可以配置为具有简单(String
)或复杂(自定义视图)的标题/页脚。自定义标题/页脚的配置方式与您为单元格所做的相同,通过使用适配器概念。
要创建一个基于简单字符串的标题/页脚,只需将其值传递到部分对象的初始化中
let newTableSection = TableSection(id: "sectionId", elements: contacts, header: "\(contacts.count) Contacts", footer: nil)
以下代码创建了一个包含该部分内联系人数的字符串标题的部分。
要创建基于视图的标题/页脚,您必须使用以下内容注册一个适配器
TableHeaderFooterAdapter<View>
类用于表格(其中View
是UITableViewHeaderFooterView
类的子类)CollectionHeaderFooterAdapter<View>
用于集合(其中View
是UICollectionReusableView
类的子类)
在这种情况下,适配器仅保留标题/页脚视图类类型的引用(不涉及显式模型)。
以下示例为集合创建了一个基于视图的标题,并使用一些自定义数据配置了它
// In our case our custom view is a class EmojiHeaderView subclass of UICollectionReusableView
let emojiHeader = CollectionHeaderFooterAdapter<EmojiHeaderView> { ctx in
ctx.events.dequeue = { ctx in // register for view dequeue events to setup some data
ctx.view?.titleLabel.text = ctx.section?.identifier ?? "-"
}
}
// Register adapter for header/footer
director?.registerHeaderFooterAdapter(headerAdapter)
一旦您已注册了您的标题/页脚,您就可以将其分配给任何部分的初始化
let newSection = CollectionSection(id: "Section \(idx)" ,elements: elements, header: emojiHeader, footer: nil)
在 ctx
参数中,您将接收到生成事件的部分。
3.6 - 使用自动动画重新加载数据
Owl支持在更改数据源之间自动动画重新加载数据。使用此功能,您不需要考虑在更改数据源时调用(甚至 以正确的顺序) reloadRows/deleteRows/removeRows
方法:只需在回调中更改模型并执行更改,在最后,Owl将生成正确的动画序列。
这是因为,在 UITableView
和 UICollectionView
的 performBatchUpdates
中,有些操作组合在同时应用时会导致崩溃。DifferenceKit 采用了一种分割差异集的方法,将差异分割成可以无崩溃执行批处理的最低阶段的集合。
以下示例
director?.reload(afterUpdate: { dir in
dir.firstSection()!.elements.shuffled() // shuffle some items
dir.firstSection()!.remove(at: 4) // remove item at index 4
// ... do any other stuff with sections or elements in section
}, completion: nil)
在调用Owl结束时,将比较前后数据模型并生成一个要执行的动画更改集,
差异算法基于优化的DifferenceKit框架,这是一个针对性能的O(n)差异算法,适用于Swift。它支持所有动画UI批更新的操作,包括分区重新加载。
与IGListKit相比,它允许
支持的集合
线性 | 分区的 | 重复的元素/分区 | |
---|---|---|---|
Owl | |||
RxDataSources | |||
IGListKit |
线性
意味着一维集合。
分区的
意味着二维集合。
支持元素差异
删除 | 插入 | 移动 | 重新加载 | 跨分区移动 | |
---|---|---|---|---|---|
Owl | |||||
RxDataSources | |||||
IGListKit |
支持的分区差异
删除 | 插入 | 移动 | 重新加载 | |
---|---|---|---|---|
Owl | ||||
RxDataSources | ||||
IGListKit |
而且,它的性能远比IGListKit 和 RxDataSources 相当对手要快!
3.7 - 使用头部和尾部
Owl中的头部和尾部使用已用于Cell的相同方式管理,使用Adapter;然而,由于它的性质(与单元格不同,它们不是严格关联到特定的模型类),您将创建一个仅链接用于渲染视图的适配器(对于 UITableView
是 UITableViewControllerHeaderFooterView
的子类,对于 UICollectionView
是 UICollectionReusableView
的子类)。
A. 创建适用于UITableView的头/尾部
使用 storyboard 在您的 UITableView
中使用头部/尾部并不容易;最可行的方法是创建一个与您的 UITableViewHeaderFooterView
子类同名的 xib 文件。
假设您想为您的联系人表创建一个名为 GroupHeaderView
的头部/尾部类。您需要创建
GroupHeaderView.xib
文件包含了类的 UI(您仍然可以通过覆盖 GroupHeaderView.swift
实现中的 ReusableCellViewProtocol
协议来自定义视图的加载方式。默认情况下,它期望一个包含 UI 的单独的 xib 文件,但您也可以直接从类中加载它或提供不同的文件名)。此 xib 文件包含一个 GroupHeaderView
视图子类。
GroupHeaderView.swift
文件包含您的类实现。在标准配置下,您不需要做任何特殊操作;只需将 UITableViewHeaderFooterView
子类化即可。
import UIKit
// Nothing special is required to support header/footer class in Owl.
// This is just a simple class.
public class GroupHeaderView: UITableViewHeaderFooterView {
@IBOutlet public var headerTitleLabel: UILabel?
@IBOutlet public var headerSubtitleLabel: UILabel?
...
}
下一步是注册您的头部/尾部适配器
let groupHeaderAdapter = TableHeaderFooterAdapter<GroupHeaderView> { config in
// Configure content of the header/footer
config.events.dequeue = { ctx in
ctx.view?.headerTitleLabel?.text = "..."
ctx.view?.headerSubtitleLabel?.text = "Section #\(ctx.section)"
}
// Configure height of the header/footer
config.events.height = { _ in
return 30
}
}
到这一步,您只需使用以下方式即可将此适配器分配给任何 TableSection
实例
let newSection = TableSection(elements: [...], headerView: groupHeaderAdapter, footerView: nil)
director?.add(section: section)
director?.reload()
以上就是全部!您可以在多个部分中使用 groupHeaderAdapter
,而不会有任何问题。
B. 为 UICollectionView 创建 Header/Footer
为集合视图创建头部/尾部的方式与之前为表格使用的方法非常相似;唯一的重大区别是,当您使用 storyboards 时,可以使用指定在集合视图中的头部/尾部(就像单元格一样)。
首先,您需要创建头部/尾部类的实例。以下示例创建了一个自定义头部,即 EmojiHeaderView.swift
import UIKit
public class EmojiHeaderView: UICollectionReusableView {
@IBOutlet public var titleLabel: UILabel!
// ...
}
至于表格的头部/尾部视图,您不需要为您的子类做任何特殊处理。
接下来,您需要为您的集合视图启用头部/尾部,并将新创建的视图设置为您的视图类子类。
现在,您已准备好创建用于保存头部/尾部的适配器。
let headerAdapter = CollectionHeaderFooterAdapter<EmojiHeaderView> { cfg in
// dequeue
cfg.events.dequeue = { ctx in
ctx.view?.titleLabel.text = ctx.section?.identifier ?? "-"
}
}
director?.registerHeaderFooterAdapter(headerAdapter)
现在,您可以将头部/尾部适配器分配给一个或多个部分实例
let section = CollectionSection(id: "Section \(idx)", elements: rawSection)
section.headerView = headerAdapter
section.headerSize = CGSize(width: self.collection.frame.size.width, height: 30)