BlueprintUILists 14.3.1

BlueprintUILists 14.3.1

Kyle BashourAndrew WattKyle Van EssenNick SillikGabriel HernandezWestin NewellNoah BlakeAlex OdawaMeher KasamRob MacEachernSteven Grosmark维护。



 
依赖项
ListableUI>= 0
BlueprintUI~> 4.0
 

  • 作者:
  • 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,这允许你在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设计时,要确保它能够以最小的性能成本支持这种规模的列表,使其容易构建而无需为性能付费,或者无需降级回标准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)与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 及表面区域

您的交互主要涉及三类类型: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 更新中按需配置列表视图。

项目

您可以把项目看作是您为列表提供的内容的包装器 —— 类似于如何使用UITableViewCellUICollectionViewCell来包装内容视图并提供了其他配置选项。

项目是您添加到节中的内容,用于在列表中表示一行。它包含您提供的主题内容(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!")

项目内容

表示项目内容的核心值类型。

通过identifierwasMovedisEquivalent方法描述给定行项目经理的内容。

要将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 – 并且默认情况下,将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
    }
}

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

同样,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类似,如果你的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 进行。

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》来简化这个过程。

你可以对每个项和每个页眉页脚基于样式的参数进行覆盖。

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

附录

实现细节

渲染 & 显示

可列举的列表是建立在UICollectionView之上的,尽管它并未公开向消费者提供自定义的UICollectionViewLayout

性能

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

视图状态管理

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

为什么?

在iOS上构建丰富且交互式的列表视图和列表仍然是一个挑战。维护状态和执行动画更改很棘手,且容易出错。往往有隐蔽的状态错误,导致数据不一致或难以诊断和调试的崩溃。

历史上,我们以几种方式管理列表视图的状态...

  1. 通过 Core Data 和 NSFetchedResultsController,它处理差异和更新。然而,这使你的UI紧密绑定到底层核心数据模型,这使得更改变得困难且容易出错。你最终需要将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

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