注意 – 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,这允许你在app的列表视图中实现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)与Blueprint紧密集成,Square为此提供了声明性UI构建和管理框架(如果您使用过SwiftUI, Blueprint和它非常相似)。可列表(Listable)提供包装类型和默认类型,使得在Blueprint元素中使用可列表(Listable)列表变得简单,并且可以轻松地将Blueprint元素构建为可列表(Listable)项目。
您只需要依赖BlueprintUILists
库,然后import 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
更新中按需配置列表视图。
项目
您可以把项目
看作是您为列表提供的内容的包装器 —— 类似于如何使用UITableViewCell
或UICollectionViewCell
来包装内容视图并提供了其他配置选项。
项目
是您添加到节中的内容,用于在列表中表示一行。它包含您提供的主题内容(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!")
项目内容
表示项目内容的核心值类型。
通过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
同样,API与ItemContent
相似,但表面积较小,因为页眉和页脚的关注点较少。
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 进行。
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
模块使用蓝图集成,您还将与以下类型进行交互。
列表
直接使用 ListView
时,您可以使用 list.configure { list in ... }
来设置列表的内容。
但是,蓝图元素树仅仅是UI描述 - 因此,列表
仅是蓝图 元素
,用于描述列表。传递给 列表 { list in ... }
的参数类型与传递给 list.configure { list in ... }
的类型相同(列表属性
)。
var elementRepresentation : Element {
List { list in
list += Section("section") { section in
section += self.podcasts.map {
PodcastRow(podcast: $0)
}
}
}
}
蓝图项内容
蓝图项内容
简化了 ItemContent
的创建过程,要求您提供一个蓝图 元素
描述,而不是视图类型和视图实例。
除非您支持突出显示和选择您的 ItemContent
,否则您不需要提供 背景元素(:)
和 选中背景元素(:)
的实现 - 它们默认返回 nil。类似于 ItemContent
,根据 Equatable
合规性,还提供了 wasMoved(:)
和 isEquivalent(:)
。
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 或其 Blueprint 封装的依赖项
s.dependency 'ListableUI'
s.dependency 'BlueprintUILists'
如果您想依赖边角生新变化,可以通过 Git 仓库在 Podfile 中添加 pods,如下所示
pod 'BlueprintUILists', git: 'ssh://[email protected]:kyleve/Listable.git'
pod 'ListableUI', git: 'ssh://[email protected]:kyleve/Listable.git'
演示项目
若要查看 Listable 在使用中的示例,请 cloning 仓库,然后在仓库根目录下运行《bundle exec pod install
》。这将创建《Demo/Demo.xcworkspace
》工作区,您可以打开并运行它。其中包含各种类型屏幕和用法示例。
其他精彩功能
您可以在其他列表中嵌套列表。
您可以将横向滚动的列表嵌入到纵向滚动列表中,以创建高级、定制的布局。Listable 提供了《ListItemElement
》来简化这个过程。
你可以对每个项和每个页眉页脚基于样式的参数进行覆盖。
通过在Item
或HeaderFooter
上设置layout
参数,你可以指定布局内每个项的对齐方式,应该有多少填充,可以有多少间距等。
附录
实现细节
渲染 & 显示
可列举的列表是建立在UICollectionView
之上的,尽管它并未公开向消费者提供自定义的UICollectionViewLayout
。
性能
在内部,性能是通过将加载到集合视图本身的内容进行透明批处理来实现的。这允许将大量内容推入列表,但ListView
足够智能,仅加载、测量、差异等足够的内容以显示当前滚动位置和一些滚动溢出。实际上,这意味着即使你在列表中放置了50,000个项目,如果用户滚动到表顶部,也只会测量、差异几百个项目,在初始渲染和更新期间占用计算时间。这允许性能保持几乎恒定,无论推入列表的内容是什么。用户滚动得越远,必须完成的计算就越多。
视图状态管理
在内部,屏幕上绘制的每个项目和列表中可见的项目都由一个长期存在的PresentationState
实例表示,该实例跟踪可见单元格、尺寸测量等。这个长期存在的对象允许额外的一层,意味着列表视图内容多次更新时易于缓存高度计算,从而允许进一步的性能改进和优化。这对开发者来说是透明的。
为什么?
在iOS上构建丰富且交互式的列表视图和列表仍然是一个挑战。维护状态和执行动画更改很棘手,且容易出错。往往有隐蔽的状态错误,导致数据不一致或难以诊断和调试的崩溃。
历史上,我们以几种方式管理列表视图的状态...
-
通过 Core Data 和 NSFetchedResultsController,它处理差异和更新。然而,这使你的UI紧密绑定到底层核心数据模型,这使得更改变得困难且容易出错。你最终需要将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
除非适用法律要求或书面同意,否则根据许可证分发的软件按“原样”基础分发,不提供任何明示或暗示的保证或条件。有关许可证的具体语言管理权限和限制,请参阅许可证。