注意 – Listable仍然是实验性的
Listable
Listable是一个用于iOS的声明式列表框架,它可以让您简洁地创建丰富的、实时更新的基于列表的布局,这些布局可以在多个维度上进行高度定制:填充、间距、列数、对齐等。它设计得非常高效:在大多数设备上无需问题的处理超过10k+的列表。
self.listView.setContent { list in
list += Section("section-1") { section in
section.header = DemoHeader(title: "This Is A Header")
section += DemoItem(text: "And here is a row")
section += DemoItem(text: "And here is another row.")
let rows = [
"You can also map rows",
"Like this"
]
section += rows.map {
DemoItem(text: $0)
}
}
list += Section("section-2") { section in
section.header = DemoHeader(title: "Another Header")
section += DemoItem(text: "The last row.")
}
}
功能
声明式接口与智能更新
Listable的核心力量和优势来自于其声明式API,这使得您能够在应用程序的列表视图中实现类似SwiftUI或React的单向数据流,消除了在使用标准UITableView或UICollectionView基于代理的解决方案时经常遇到的状态管理错误。您只需要告诉列表现在应该包含什么内容——它会执行困难的差异化更改,以便在提供新内容时进行丰富的动画更新。
比如说,您从一个空表开始,如下所示
self.listView.setContent { list in
// No content right now.
}
然后推送新的内容,现在有一个部分的一行
self.listView.setContent { list in
list += Section("section-1") { section in
section.header = DemoHeader(title: "This Is A Header")
section += DemoItem(text: "And here is a row")
}
}
这个新的部分将通过动画放置到正确的位置。如果您那时插入另一行
self.listView.setContent { list in
list += Section("section-1") { section in
section.header = DemoHeader(title: "This Is A Header")
section += DemoItem(text: "And here is a row")
section += DemoItem(text: "Another row!")
}
}
列表也会通过动画将其放置到位。对于您对所有表格做出的任何更改也是如此——会执行差异,并将更改通过动画放置到位。在更新之间未更改的内容将不受影响。
高性能
Listable的一个核心设计原则是性能!列表通常是通常较小的,但并非总是!例如,在Square Point of Sale中,卖家可能有一个包含1,000、10,000或更多项目的商品目录。在设计Listable时,确保它能以最低的性能成本支持这些规模的列表,使其容易构建,而无需为性能付费,或者无需降级到标准UITableView
或UICollectionView
API,这些API容易被误用。
这种性能是通过内部批处理系统实现的,该系统仅查询和差异化当前滚动点所需的条目以及一些滚动溢出项。只为当前屏幕上的视图创建视图。这允许对长列表中推入的几乎所有内容进行裁剪。
此外,高度和尺寸测量比常规集合视图实现更有效地缓存,对于大型列表,这可以提升滚动性能并防止掉帧。
高度可定制
Listable对您的内容外观几乎没有假设。您使用的货币是普通的UIViews
(不是UICollectionViewCells
),因此您可以以任何方式绘制内容。
此外,由ListView
提供的布局和外观控制允许对布局进行定制,以几乎任何所需方式绘制列表。
这主要通过Appearance
对象控制
public struct Appearance : Equatable
{
public var backgroundColor : UIColor
public var direction : LayoutDirection
public var stickySectionHeaders : Bool
public var list : TableAppearance
}
您使用TableAppearance.Sizing
结构体来控制列表内的默认测量值:标准行、头部、尾部等有多高。
public struct TableAppearance.Sizing : Equatable
{
public var itemHeight : CGFloat
public var sectionHeaderHeight : CGFloat
public var sectionFooterHeight : CGFloat
public var listHeaderHeight : CGFloat
public var listFooterHeight : CGFloat
public var itemPositionGroupingHeight : CGFloat
}
可以使用 TableAppearance.Layout
来自定义整个列表的填充,调整列表的宽度(例如,最大700px,超过400px等),以及控制项、标题和页脚之间的间距。
public struct TableAppearance.Layout : Equatable
{
public var padding : UIEdgeInsets
public var width : WidthConstraint
public var interSectionSpacingWithNoFooter : CGFloat
public var interSectionSpacingWithFooter : CGFloat
public var sectionHeaderBottomSpacing : CGFloat
public var itemSpacing : CGFloat
public var itemToSectionFooterSpacing : CGFloat
public var stickySectionHeaders : Bool
}
最后,Behavior
和 Behavior.Underflow
允许自定义列表内容长度小于其容器视图时的情况:滚动视图是否要弹回,内容是否要居中,等等。
public struct Behavior : Equatable
{
public var keyboardDismissMode : UIScrollView.KeyboardDismissMode
public var underflow : Underflow
struct Underflow : Equatable
{
public var alwaysBounce : Bool
public var alignment : Alignment
public enum Alignment : Equatable
{
case top
case center
case bottom
}
}
自适应单元格
标准 UITableView
或 UICollectionView
的另一个常见痛点是处理动态和自适应单元格。Listable 会为你隐式地处理这些问题,并提供了许多调整内容大小的方法。每个 Item
都有一个 sizing
属性,可以设置为以下值之一。其中,.default
从上述提到的 List.Measurement
中获取项目的默认大小,而 thatFits
和 autolayout
则分别基于 sizeThatFits
和 systemLayoutSizeFitting
调整项目大小。
public enum Sizing : Equatable
{
case `default`
case fixed(CGFloat)
case thatFits(Constraint = .noConstraint)
case autolayout(Constraint = .noConstraint)
}
与 Blueprint 集成
列表 (Listable) 与 Square 的声明式 UI 构建和管理框架 Blueprint 紧密集成(如果你熟悉 SwiftUI,那么 Blueprint 与之类似)。列表 (Listable) 提供了封装类型和默认类型,以便在 Blueprint 元素中使用列表 (Listable) 列表变得简单,并且可以轻松地将列表 (Listable) 项构建成 Blueprint 元素。
你只需要依赖 BlueprintUILists
pod,然后导入 BlueprintUILists
即可开始使用 Blueprint 集成。
以下示例演示如何在 Blueprint 元素层次结构中声明一个 List
。
var elementRepresentation : Element {
List { list in
list += Section("podcasts") { section in
section += self.podcasts.map {
PodcastRow(podcast: $0)
}
}
}
}
在这个示例中,我们可以看到如何创建一个简单的 BlueprintItemContent
,它使用 Blueprint 渲染其内容。
struct DemoItem : BlueprintItemContent, Equatable
{
var text : String
// ItemContent
var identifierValue: String {
return self.text
}
// BlueprintItemContent
func element(with info : ApplyItemContentInfo) -> Element
{
var box = Box(
backgroundColor: .white,
cornerStyle: .rounded(radius: 6.0),
wrapping: Inset(
uniformInset: 10.0,
wrapping: Label(text: self.text)
)
)
box.borderStyle = .solid(color: .white(0.9), width: 2.0)
return box
}
}
与 Instruments.app 集成
Listable 为您的应用程序提供了与 os_signpost
API 的集成,以测量事件的持续时间。如果您在生产列表性能方面遇到问题,您可以在 Instruments 中进行性能分析,并将 os_signpost
工具添加以检查各种布局和更新遍历的时序。
主要 API 及表面区域
您的交互主要涉及三种主要的类型族:ListView
、Item
、HeaderFooter
和 Section
。
ListView
您将内容放入列表!在分配列表并将其显示在屏幕上之外,您与 ListView
的交互的大部分将通过上图中显示的 setContent
API 完成。
self.listView.setContent { list in
// Set list appearance, specify content, etc...
}
那么那个 list
参数是什么,您可能会问……?
ListProperties
ListProperties
是一个结构,包含渲染列表更新所需的所有信息。
public struct ListProperties
{
public var animatesChanges : Bool
public var layoutType : ListLayoutType
public var appearance : Appearance
public var behavior : Behavior
public var autoScrollAction : AutoScrollAction
public var scrollInsets : ScrollInsets
public var accessibilityIdentifier: String?
public var content : Content
}
这允许您在 configure
更新中根据需要配置列表视图。
Item
您可以将 Item
视为用于将 您 提供给列表内容的包装器 - 类似于如何使用 UITableViewCell
或 UICollectionViewCell
来包裹内容视图并提供其他配置选项。
一个 Item
是你添加到某个部分以表示列表中的一行的内容。它包含了你的提供内容(ItemContent
),以及尺寸、布局定制、选择行为、重排序行为等,这些都是在选中、显示等操作时执行的操作。
public struct Item<Content:ItemContent> : AnyItem
{
public var identifier : Content.Identifier
public var content : Content
public var sizing : Sizing
public var layout : ItemLayout
public var selection : ItemSelection
public var swipeActions : SwipeActions?
public var reordering : ItemReordering?
public typealias OnSelect = (Content) -> ()
public var onSelect : OnSelect?
public typealias OnDeselect = (Content) -> ()
public var onDeselect : OnDeselect?
public typealias OnDisplay = (Content) -> ()
public var onDisplay : OnDisplay?
public typealias OnEndDisplay = (Content) -> ()
public var onEndDisplay : OnEndDisplay?
}
你可以通过 add
函数或通过 +=
覆盖来将一个项添加到部分。
section += Item(
YourContent(title: "Hello, World!"),
sizing: .default,
selection: .notSelectable
)
然而,如果你想要使用 Item
初始化器的所有默认值,你可以跳过一个步骤,直接将你的 ItemContent
添加到部分中。
section += YourContent(title: "Hello, World!")
ItemContent
表示项内容的核心值类型。
此视图模型通过 identifier
描述了给定行/项的内容,还通过 wasMoved
和 isEquivalent
方法。
要将 ItemContent
转换为用于显示的视图,将调用 createReusableContentView(:)
方法来创建可重用的视图以用于显示内容(背景视图也按此方式处理)。
为了准备用于显示的视图,将调用 apply(to:for:with:)
方法,这是你将你的 ItemContent
中的内容推送到提供的视图中的地方。
public protocol ItemContent
{
associatedtype IdentifierValue : Hashable
var identifierValue : IdentifierValue { get }
func apply(
to views : ItemContentViews<Self>,
for reason: ApplyReason,
with info : ApplyItemContentInfo
)
func wasMoved(comparedTo other : Self) -> Bool
func isEquivalent(to other : Self) -> Bool
associatedtype ContentView:UIView
static func createReusableContentView(frame : CGRect) -> ContentView
associatedtype BackgroundView:UIView = UIView
static func createReusableBackgroundView(frame : CGRect) -> BackgroundView
associatedtype SelectedBackgroundView:UIView = BackgroundView
static func createReusableSelectedBackgroundView(frame : CGRect) -> SelectedBackgroundView
}
然而,你通常不需要实现所有这些方法!例如,如果你的 ItemContent
是 Equatable
,则你可以免费获得 isEquivalent
– 默认情况下,wasMoved
与 isEquivalent(other:) == false
相同。
public extension ItemContent
{
func wasMoved(comparedTo other : Self) -> Bool
{
return self.isEquivalent(to: other) == false
}
}
public extension ItemContent where Self:Equatable
{
func isEquivalent(to other : Self) -> Bool
{
return self == other
}
}
默认情况下,BackgroundView
和 SelectedBackgroundView
视图都是普通的 UIView
,不显示任何自己的内容。只有在你希望支持对项在高亮和选择时的外观进行定制时,才需要提供这些背景视图。
public extension ItemContent where BackgroundView == UIView
{
static func createReusableBackgroundView(frame : CGRect) -> BackgroundView
{
BackgroundView(frame: frame)
}
}
默认情况下,SelectedBackgroundView
也是 BackgroundView
的类型,除非你明确想要两种不同的视图类型。
public extension ItemContent where BackgroundView == SelectedBackgroundView
{
static func createReusableSelectedBackgroundView(frame : CGRect) -> BackgroundView
{
self.createReusableBackgroundView(frame: frame)
}
}
这有点抽象,所以考虑以下示例:一个提供标题和详细标签的 ItemContent
。
struct SubtitleItem : ItemContent, Equatable
{
var title : String
var detail : String
// ItemContent
func apply(to views : ItemContentViews<Self>, for reason: ApplyReason, with info : ApplyItemContentInfo)
{
views.content.titleLabel.text = self.title
views.content.detailLabel.text = self.detail
}
typealias ContentView = View
static func createReusableContentView(frame : CGRect) -> ContentView
{
View(frame: frame)
}
private final class View : UIView
{
let titleLabel : UILabel
let detailLabel : UILabel
...
}
}
HeaderFooter
如何在列表中描述标题或页脚。API 与 Item
非常相似,但内容较少,因为标题和页脚是仅显示的。
public struct HeaderFooter<Content:HeaderFooterContent> : AnyHeaderFooter
{
public var content : Content
public var sizing : Sizing
public var layout : HeaderFooterLayout
}
你通过 header
和 footer
参数设置部分上的标题和页脚。
self.listView.configure { list in
list += Section("section-1") { section in
section.header = DemoHeader(title: "This Is A Header")
section.footer = DemoFooter(text: "And this is a footer. Please check the EULA for details.")
}
}
HeaderFooterContent
又一次,一个与ItemContent
相似的API,但由于头部和脚部关注点减少,表面面积更小。
public protocol HeaderFooterContent
{
func apply(to view : Appearance.ContentView, reason : ApplyReason)
func isEquivalent(to other : Self) -> Bool
associatedtype ContentView:UIView
static func createReusableContentView(frame : CGRect) -> ContentView
associatedtype BackgroundView:UIView
static func createReusableBackgroundView(frame : CGRect) -> BackgroundView
associatedtype PressedBackgroundView:UIView
static func createReusablePressedBackgroundView(frame : CGRect) -> PressedBackgroundView
}
与Item
一样,如果您的HeaderFooterContent
是Equatable
,您将免费获得isEquivalent
。
public extension HeaderFooterContent where Self:Equatable
{
func isEquivalent(to other : Self) -> Bool
{
return self == other
}
}
标准实现可能如下所示
struct Header : HeaderFooterContent, Equatable
{
var title : String
func apply(to view : Appearance.ContentView, for reason: ApplyReason)
{
view.titleLabel.text = self.title
}
typealias ContentView = View
static func createReusableContentView(frame : CGRect) -> ContentView
{
View(frame: frame)
}
private final class View : UIView
{
let titleLabel : UILabel
...
}
}
Section
Section
- 惊奇的是 - 表示列表中的一个给定部分。您将与Section
的交互主要通过网络API实例化和构造器API,如上所示。
Section("section") { section in
section += self.podcasts.map {
PodcastRow(podcast: $0)
}
}
但是,部分具有许多属性以允许进行配置。您可以自定义布局、列数和布局、设置头部和脚部,并通过items
属性提供项,显然通过许多提供的+=
运算符覆盖。
public struct Section
{
public var layout : Layout
public var columns : Columns
public var header : AnyHeaderFooter?
public var footer : AnyHeaderFooter?
public var items : [AnyItem]
}
集成至蓝图
如果您通过BlueprintUILists
模块使用蓝图集成,您还将与以下类型交互。
List
当直接使用ListView
时,您可以使用list.configure { list in ... }
来设置列表的内容。
然而,蓝图元素树只是用户界面的描述——因此,列表
只是一个描述列表的蓝图元素
。传递给列表 {列表 in ... }
的参数类型(ListProperties
)与传递给list.configure {列表 in ... }
的类型相同。
var elementRepresentation : Element {
List { list in
list += Section("section") { section in
section += self.podcasts.map {
PodcastRow(podcast: $0)
}
}
}
}
蓝图项内容
蓝图项内容
简化了内容项
的创建过程,要求你提供蓝图元素
描述,而不是视图类型和实例。
除非你正在支持内容项
的高亮和选择,你不需要提供backgroundElement(:)
和selectedBackgroundElement(:)
的实现 —— 它们默认返回nil。与内容项
类似,wasMoved(:)
和isEquivalent(:)
也是基于Equatable
协议提供的。
public protocol BlueprintItemContent : ItemContent
{
associatedtype IdentifierValue : Hashable
var identifierValue : IdentifierValue { get }
func wasMoved(comparedTo other : Self) -> Bool
func isEquivalent(to other : Self) -> Bool
func element(with info : ApplyItemContentInfo) -> Element
func backgroundElement(with info : ApplyItemContentInfo) -> Element?
func selectedBackgroundElement(with info : ApplyItemContentInfo) -> Element?
}
一个标准的蓝图项内容
可能如下所示
struct MyPerson : BlueprintItemContent, Equatable
{
var name : String
var phoneNumber : String
var identifierValue : String {
self.name
}
func element(with info : ApplyItemContentInfo) -> Element {
Row {
$0.add(child: Label(text: name))
$0.add(child: Spacer())
$0.add(child: Label(text: name))
}
.inset(by: 15.0)
}
}
蓝图头部和底部内容
类似地,蓝图头部和底部内容
使得创建头部或底部变得简单——只需要实现elementRepresentation
,这为你的头部或底部提供内容元素。通常,如果你的类型支持Equatable
,就会提供isEquivalent(to:)
。
public protocol BlueprintHeaderFooterContent : HeaderFooterElement
{
func isEquivalent(to other : Self) -> Bool
var elementRepresentation : Element { get }
}
一个标准的蓝图头部和底部内容
可能如下所示
struct MyHeader : BlueprintHeaderFooterContent, Equatable
{
var name : String
var itemCount : String
var elementRepresentation : Element {
Row {
$0.add(child: Label(text: name))
$0.add(child: Spacer())
$0.add(child: Label(text: itemCount))
}
.inset(by: 15.0)
}
}
入门指南
Listable已发布在CocoaPods上。你可以在Podspec中添加对Listable或其蓝图包装器的依赖,如下所示:
s.dependency 'ListableUI'
s.dependency 'BlueprintUILists'
如果你希望依赖最新的更改,你可以通过git仓库将这些pods添加到Podfile中,如下所示:
pod 'BlueprintUILists', git: 'ssh://[email protected]:kyleve/Listable.git'
pod 'ListableUI', git: 'ssh://[email protected]:kyleve/Listable.git'
演示项目
如果您想查看Listable的示例用法,请克隆仓库,然后在仓库根目录下运行bundle exec pod install
。这将创建Demo/Demo.xcworkspace
工作空间,您可以打开并运行它,其中包含各种类型屏幕和用例的示例。
其他巧妙的玩意儿
您可以将列表嵌套在其他列表中。
您可以将支持水平滚动的列表嵌入到支持垂直滚动的列表中,以创建高级的自定义布局。Listable提供了ListItemElement
来简化这一过程。
您可以根据每个项目和每个头部/尾部来覆盖许多布局参数。
通过在Item
或HeaderFooter
上设置layout
参数,您可以指定布局中每个项目的对齐方式,应有多少填充,可以有多少间距等。
附录
实现细节
渲染与显示
Listable是在UICollectionView基础上构建的,尽管有一个自定义的UICollectionViewLayout,但这并不向消费者公开。
性能
内部,通过在collection view本身中透明地批量处理加载的内容来实现性能。这允许将大量内容推入列表,但是ListView足够智能,只会加载、测量、diff等足够的此类内容来显示当前的滚动位置,以及一些滚动溢出。实际上,这意味着即使你在一个列表中放入50,000条项目,如果用户滚动到表格的顶部,也只会测量、diff和占用计算时间的一小部分项目,在初始渲染和更新期间。这使得无论向列表中推送什么内容,性能都几乎保持恒定。用户滚动得越深,必须完成的计算就越多。
视图状态管理
在内部,屏幕上绘制且在列表中可见的每个项目都由一个长久的PresentationState
实例表示,该实例跟踪可见的单元格、尺寸测量等。这个长久对象允许额外的一层,这意味着在列表视图的多项内容更新中缓存高度计算变得容易,从而允许进一步的性能提升和优化。这对开发者来说是透明的。
为什么?
在iOS上构建丰富和交互式的列表视图和列表仍然是一个挑战。在变化时维护状态和执行动画很复杂且易于出错。往往存在一些潜在的状态错误,导致数据不一致或难以诊断和调试的崩溃。
历史上,我们通过一些方法来管理列表视图状态...
-
通过Core Data和NSFetchedResultsController,它处理差异和更新。然而,这会紧密地将UI绑定到底层数据模型,这使得更改变得困难且易出错。你最终需要在Core Data模型中深入建模UI关注点,以便按你想的方式对数据进行排序和分节。这根本不是好方法。
-
使用其他选项,例如通用的基于块表格视图或集合视图构建器,它可以从开发者那里抽象出一些复杂性,但是它仍然在单元格和视图的货币上操作,并使得适当处理动画和更新变得困难。
-
有时,你只能放弃,当表的数据源中的任何内容发生变化时都调用reloadData() - 这很糟糕,因为用户看不到表明发生了什么更改的动画。
-
更糟糕的是,你必须自己管理插入、删除和更新,通常是这样的…
调用beginUpdates
调用insertRow:atIndexPath: 调用insertRow:atIndexPath: 调用moveRowAtIndexPath:toIndexPath: 等。
调用endUpdates
在UITableView/UICollectionView.m:20000000处的断言失败:更新前的行数不等于更新后的行数加(或减)已添加和删除的行数。笨蛋,你在哪里错并购呢!
[崩溃]
不用说,这些选项都不好,所有这些都是2011年左右的最新技术 - 那是很久以前了。
法律事宜
版权所有 © 2019 Square, Inc.
根据Apache许可证版本2.0(以下简称“许可证”)授权,除非适用法律要求或经书面同意,否则不得使用此文件。您可以在以下链接获取许可证副本:
http://www.apache.org/licenses/LICENSE-2.0
除非适用法律或书面同意要求,否则根据许可证分发软件的基础是“按原样”,不提供任何明示或暗示的保证或条件。有关许可证下具体许可权限和限制的具体语言请参阅许可证。