ListableUI 14.3.1

ListableUI 14.3.1

由以下人员维护:Kyle BashourAndrew WattKyle Van EssenNick SillikGabriel HernandezWestin NewellNoah BlakeAlex OdawaMeher KasamRob MacEachernSteven Grosmark



ListableUI 14.3.1

  • Kyle

注意 – Listable仍然是实验性的🙈虽然它在Square点销售的多个地方都在使用,但我们仍在积极迭代API并填充全面的测试。因此,请预期在未来几个月内会出现问题和变化。

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时,确保它能以最低的性能成本支持这些规模的列表,使其容易构建,而无需为性能付费,或者无需降级到标准UITableViewUICollectionView 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
}

最后,BehaviorBehavior.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
    }
}

自适应单元格

标准 UITableViewUICollectionView 的另一个常见痛点是处理动态和自适应单元格。Listable 会为你隐式地处理这些问题,并提供了许多调整内容大小的方法。每个 Item 都有一个 sizing 属性,可以设置为以下值之一。其中,.default 从上述提到的 List.Measurement 中获取项目的默认大小,而 thatFitsautolayout 则分别基于 sizeThatFitssystemLayoutSizeFitting 调整项目大小。

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 及表面区域

您的交互主要涉及三种主要的类型族:ListViewItemHeaderFooterSection

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 视为用于将 提供给列表内容的包装器 - 类似于如何使用 UITableViewCellUICollectionViewCell 来包裹内容视图并提供其他配置选项。

一个 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 描述了给定行/项的内容,还通过 wasMovedisEquivalent 方法。

要将 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
}

然而,你通常不需要实现所有这些方法!例如,如果你的 ItemContentEquatable,则你可以免费获得 isEquivalent – 默认情况下,wasMovedisEquivalent(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
    }
}

默认情况下,BackgroundViewSelectedBackgroundView 视图都是普通的 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
}

你通过 headerfooter 参数设置部分上的标题和页脚。

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一样,如果您的HeaderFooterContentEquatable,您将免费获得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来简化这一过程。

您可以根据每个项目和每个头部/尾部来覆盖许多布局参数。

通过在ItemHeaderFooter上设置layout参数,您可以指定布局中每个项目的对齐方式,应有多少填充,可以有多少间距等。

附录

实现细节

渲染与显示

Listable是在UICollectionView基础上构建的,尽管有一个自定义的UICollectionViewLayout,但这并不向消费者公开。

性能

内部,通过在collection view本身中透明地批量处理加载的内容来实现性能。这允许将大量内容推入列表,但是ListView足够智能,只会加载、测量、diff等足够的此类内容来显示当前的滚动位置,以及一些滚动溢出。实际上,这意味着即使你在一个列表中放入50,000条项目,如果用户滚动到表格的顶部,也只会测量、diff和占用计算时间的一小部分项目,在初始渲染和更新期间。这使得无论向列表中推送什么内容,性能都几乎保持恒定。用户滚动得越深,必须完成的计算就越多。

视图状态管理

在内部,屏幕上绘制且在列表中可见的每个项目都由一个长久的PresentationState实例表示,该实例跟踪可见的单元格、尺寸测量等。这个长久对象允许额外的一层,这意味着在列表视图的多项内容更新中缓存高度计算变得容易,从而允许进一步的性能提升和优化。这对开发者来说是透明的。

为什么?

在iOS上构建丰富和交互式的列表视图和列表仍然是一个挑战。在变化时维护状态和执行动画很复杂且易于出错。往往存在一些潜在的状态错误,导致数据不一致或难以诊断和调试的崩溃。

历史上,我们通过一些方法来管理列表视图状态...

  1. 通过Core Data和NSFetchedResultsController,它处理差异和更新。然而,这会紧密地将UI绑定到底层数据模型,这使得更改变得困难且易出错。你最终需要在Core Data模型中深入建模UI关注点,以便按你想的方式对数据进行排序和分节。这根本不是好方法。

  2. 使用其他选项,例如通用的基于块表格视图或集合视图构建器,它可以从开发者那里抽象出一些复杂性,但是它仍然在单元格和视图的货币上操作,并使得适当处理动画和更新变得困难。

  3. 有时,你只能放弃,当表的数据源中的任何内容发生变化时都调用reloadData() - 这很糟糕,因为用户看不到表明发生了什么更改的动画。

  4. 更糟糕的是,你必须自己管理插入、删除和更新,通常是这样的…

调用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

除非适用法律或书面同意要求,否则根据许可证分发软件的基础是“按原样”,不提供任何明示或暗示的保证或条件。有关许可证下具体许可权限和限制的具体语言请参阅许可证。