SwiftTabler

一个用于表格数据的多平台 SwiftUI 组件。

作为一个开源库提供,可集成到 SwiftUI 应用中。

SwiftTabularOpenAlloc 开源 Swift 软件工具系列的一部分。

macOS iOS

特性

支持三种表格类型,由其表头和行的渲染机制决定。

列表 (List)

堆叠 (Stack)

网格 (Grid)

* 其他平台如 macCatalyst、Mac 上的 iPad、watchOS、tvOS 等支持不佳,甚至完全不支持。 欢迎贡献以改进支持!

** AnyView 仅用于在配置中指定排序图像,这不应影响可扩展性。

Tabler 示例

下面的示例展示了如何使用 TablerList (一个基于 List 的简单变体) 从值数组中显示表格数据。

import SwiftUI
import Tabler

struct Fruit: Identifiable {
    var id: String
    var name: String
    var weight: Double
    var color: Color
}

struct ContentView: View {

    @State private var fruits: [Fruit] = [
        Fruit(id: "🍌", name: "Banana", weight: 118, color: .brown),
        Fruit(id: "🍓", name: "Strawberry", weight: 12, color: .red),
        Fruit(id: "🍊", name: "Orange", weight: 190, color: .orange),
        Fruit(id: "🥝", name: "Kiwi", weight: 75, color: .green),
        Fruit(id: "🍇", name: "Grape", weight: 7, color: .purple),
        Fruit(id: "🫐", name: "Blueberry", weight: 2, color: .blue),
    ]
    
    private var gridItems: [GridItem] = [
        GridItem(.flexible(minimum: 35, maximum: 40), alignment: .leading),
        GridItem(.flexible(minimum: 100), alignment: .leading),
        GridItem(.flexible(minimum: 40, maximum: 80), alignment: .trailing),
        GridItem(.flexible(minimum: 35, maximum: 50), alignment: .leading),
    ]

    private typealias Context = TablerContext<Fruit>

    private func header(ctx: Binding<Context>) -> some View {
        LazyVGrid(columns: gridItems) {
            Text("ID")
            Text("Name")
            Text("Weight")
            Text("Color")
        }
    }
    
    private func row(fruit: Fruit) -> some View {
        LazyVGrid(columns: gridItems) {
            Text(fruit.id)
            Text(fruit.name).foregroundColor(fruit.color)
            Text(String(format: "%.0f g", fruit.weight))
            Image(systemName: "rectangle.fill").foregroundColor(fruit.color)
        }
    }

    var body: some View {
        TablerList(header: header,
                   row: row,
                   results: fruits)
    }
}

虽然这里使用 LazyVGrid 来包裹表头和行项目,但您也可以选择使用 HStack 或类似的机制来包裹它们。

Tabler 视图

Tabler 提供了二十七 (27) 种表格视图变体供您选择。 它们可以按以下方式分类:

表格视图 类型 选择 引用 绑定 筛选
TablerList 列表 (List)
TablerListB 列表 (List) ✓*
TablerListC 列表 (List)
TablerList1 列表 (List) 单选 (Single)
TablerList1B 列表 (List) 单选 (Single) ✓*
TablerList1C 列表 (List) 单选 (Single)
TablerListM 列表 (List) 多选 (Multi)
TablerListMB 列表 (List) 多选 (Multi) ✓*
TablerListMC 列表 (List) 多选 (Multi)
TablerStack 堆叠 (Stack)
TablerStackB 堆叠 (Stack) ✓*
TablerStackC 堆叠 (Stack)
TablerStack1 堆叠 (Stack) 单选 (Single)
TablerStack1B 堆叠 (Stack) 单选 (Single) ✓*
TablerStack1C 堆叠 (Stack) 单选 (Single)
TablerStackM 堆叠 (Stack) 多选 (Multi)
TablerStackMB 堆叠 (Stack) 多选 (Multi) ✓*
TablerStackMC 堆叠 (Stack) 多选 (Multi)
TablerGrid 网格 (Grid)
TablerGridB 网格 (Grid)
TablerGridC 网格 (Grid)
TablerGrid1 网格 (Grid) 单选 (Single)
TablerGrid1B 网格 (Grid) 单选 (Single)
TablerGrid1C 网格 (Grid) 单选 (Single)
TablerGridM 网格 (Grid) 多选 (Multi)
TablerGridMB 网格 (Grid) 多选 (Multi)
TablerGridMC 网格 (Grid) 多选 (Multi)

* 绑定值的筛选功能可能因目前的实现方式而导致可扩展性不佳。 如果您能找到更好的实现方式,请提交 pull request!

表头/表尾

您可以选择为表格附加表头(或表尾)

var body: some View {
    TablerList(header: header,
               footer: footer,
               row: row,
               results: fruits)
}

private func header(ctx: Binding<Context>) -> some View {
    LazyVGrid(columns: gridItems) {
        Text("ID")
        Text("Name")
        Text("Weight")
        Text("Color")
    }
}

private func footer(ctx: Binding<Context>) -> some View {
    LazyVGrid(columns: gridItems) {
        Text("ID")
        Text("Name")
        Text("Weight")
        Text("Color")
    }
}

如果您不需要表头(或表尾),只需在表格的声明中省略即可。

对于基于 列表 (List) 的变体,表头和表尾位于滚动区域内部。 对于基于 堆叠 (Stack)网格 (Grid) 的变体,它们位于外部。 (一旦任何缩放/性能问题得到解决,这可能会在某个时候变为可配置。)

列排序

列排序可通过 tablerSort 视图函数获得。

下面的示例展示了表头项目如何支持排序。

.columnTitle() 是一个方便的函数,它显示表头名称以及指示当前排序状态的指示器(如果有)。 或者,构建您自己的表头并调用 .indicator() 方法以获取活动指示器图像。

默认情况下,箭头图像用作指示器,但可以配置(请参阅下面的“配置”部分)。

随机访问集合 (Random Access Collection)

来自 TablerDemo 应用

private typealias Context = TablerContext<Fruit>
private typealias Sort = TablerSort<Fruit>

private func header(ctx: Binding<Context>) -> some View {
    LazyVGrid(columns: gridItems) {
        Sort.columnTitle("ID", ctx, \.id)
            .onTapGesture { tablerSort(ctx, &fruits, \.id) { $0.id < $1.id } }
        Sort.columnTitle("Name", ctx, \.name)
            .onTapGesture { tablerSort(ctx, &fruits, \.name) { $0.name < $1.name } }
        Sort.columnTitle("Weight", ctx, \.weight)
            .onTapGesture { tablerSort(ctx, &fruits, \.weight) { $0.weight < $1.weight } }
        Text("Color")
    }
}

Core Data

用于 Core Data 的排序方法有所不同。 来自 TablerCoreDemo 应用

private typealias Context = TablerContext<Fruit>
private typealias Sort = TablerSort<Fruit>

private func header(ctx: Binding<Context>) -> some View {
    LazyVGrid(columns: gridItems, alignment: .leading) {
        Sort.columnTitle("ID", ctx, \.id)
            .onTapGesture { fruits.sortDescriptors = [tablerSort(ctx, \.id)] }
        Sort.columnTitle("Name", ctx, \.name)
            .onTapGesture { fruits.sortDescriptors = [tablerSort(ctx, \.name)] }
        Sort.columnTitle("Weight", ctx, \.weight)
            .onTapGesture { fruits.sortDescriptors = [tablerSort(ctx, \.weight)] }
    }
}

对计算列进行排序

如果某个计算值没有可用于存储在排序上下文中的键路径,请创建一个占位符键路径。

extension Holding {
    func getMarketValue(_ priceMap: [String: Double]) -> Double {
        shareCount * (priceMap[ticker] ?? 0)
    }

    var marketValuePlaceholder: Double { 0 }
}

struct HoldingsTable: View {
    private typealias Context = TablerContext<Holding>
    
    private func header(_ ctx: Binding<Context>) -> some View {
        LazyVGrid(columns: gridItems) {
            // ...
            Sort.columnTitle("Market Value", ctx, \.marketValuePlaceholder)
                .onTapGesture {
                    tablerSort(ctx, &model.holdings, \.marketValuePlaceholder) { 
                        $0.getMarketValue(priceMap) < $1.getMarketValue(priceMap) 
                    }
                }
        }
    }
}

绑定数据

macOS iOS

当与“绑定”视图(例如,TablerListBTablerListC)一起使用时,可以直接修改数据,从而更改您的数据源。 来自演示:

private func brow(fruit: BoundValue) -> some View {
    LazyVGrid(columns: gridItems) {
        Text(fruit.wrappedValue.id)
        TextField("Name", text: fruit.name)
            .textFieldStyle(.roundedBorder)
        Text(String(format: "%.0f g", fruit.wrappedValue.weight))
        ColorPicker("Color", selection: fruit.color)
            .labelsHidden()
    }
}

对于值源,BoundValue 是一个绑定 (binding)

typealias BoundValue = Binding<Fruit>

对于引用源,包括 Core Data,BoundValue 是一个对象包装器(又名“ProjectedValue”)

typealias BoundValue = ObservedObject<Fruit>.Wrapper

请注意,对于 Core Data,用户的更改需要保存到托管对象上下文 (Managed Object Context) 中。 有关如何执行此操作的示例,请参阅 TablerCoreDemo 代码。

行背景

您可以选择指定行背景,例如为了传达信息,或作为选择指示器。

顾名思义,行背景位于行的后面

macOS iOS

使用行背景来传达信息的示例,如上所示

var body: some View {
    TablerList(header: header,
               row: row,
               rowBackground: rowBackground,
               results: fruits)
}

private func rowBackground(fruit: Fruit) -> some View {
    LinearGradient(gradient: .init(colors: [fruit.color, fruit.color.opacity(0.2)]),
                   startPoint: .top, 
                   endPoint: .bottom)
}

使用行背景作为选择指示器的示例,例如对于没有原生选择指示器的基于 堆叠 (Stack) 的表格

@State private var selected: Fruit.ID? = nil

var body: some View {
    TablerStack1(header: header,
                 row: row,
                 rowBackground: rowBackground,
                 results: fruits,
                 selected: $selected)
}

private func rowBackground(fruit: Fruit) -> some View {
    RoundedRectangle(cornerRadius: 5)
        .fill(fruit.id == selected ? Color.accentColor : Color.clear)
}

行叠加层

与行背景类似,叠加层可用于传达信息,或用作选择指示器。

顾名思义,行叠加层位于行的上面

使用行叠加层作为选择指示器的示例

@State private var selected: Fruit.ID? = nil

var body: some View {
    TablerStack1(header: header,
                 row: row,
                 rowOverlay: rowOverlay,
                 results: fruits,
                 selected: $selected)
}

private func rowOverlay(fruit: Fruit) -> some View {
    RoundedRectangle(cornerRadius: 5)
        .strokeBorder(fruit.id == selected ? .white : .clear,
                      lineWidth: 2,
                      antialiased: true)
}

悬停事件

仅对于 macOS,您可以捕获悬停事件,通常用于高亮显示鼠标光标下的行。

@State private var hovered: Fruit.ID? = nil

var body: some View {
    TablerList(.init(onHover: hoverAction),
               header: header,
               row: row,
               rowBackground: rowBackground,
               results: fruits)
}

private func rowBackground(fruit: Fruit) -> some View {
    RoundedRectangle(cornerRadius: 5)
        .fill(Color.accentColor.opacity(hovered == fruit.id ? 0.2 : 0.0))
}

private func hoverAction(fruitID: Fruit.ID, isHovered: Bool) {
    if isHovered { hovered = fruitID } else { hovered = nil }
}

要协调悬停与其他背景(例如,用于 堆叠 (Stack) 表格上的选择)的效果,请参阅演示应用。

移动行

基于 列表 (List) 的变体支持通过拖放移动行。

TablerDemo 中看到的用于随机访问集合的示例

var body: some View {
    TablerList(.init(onMove: moveAction),
               row: row,
               results: fruits)
}

private func moveAction(from source: IndexSet, to destination: Int) {
    fruits.move(fromOffsets: source, toOffset: destination)
}

TODO 需要 Core Data 示例,如果可以做到的话。

配置

var body: some View {
    TablerList(.init(onMove: moveAction,
                     filter: { $0.weight > 10 },
                     onHover: hoverAction),
               header: header,
               row: row,
               results: fruits)
}

配置选项将因表格类型而异。

默认值可能因平台(macOS、iOS 等)而异。 请参阅代码了解具体信息。

间距默认值旨在实现表格类型之间外观的统一,其中 列表 (List) 类型作为标准。

基本默认值

基本默认值在 TablerConfig 模块中定义。

列表 (List)

列表 (List) 配置是可选的。

TablerListConfig<Element>.init 参数

堆叠 (Stack)

堆叠 (Stack) 配置是可选的。

TablerStackConfig<Element>.init 参数

网格 (Grid)

网格 (Grid) 配置是必需的,您需要提供 GridItem 数组。

TablerGridConfig<Element>.init 参数

水平滚动

在紧凑型显示器上,您可能希望水平滚动表格。

您可以将其包裹在您自己的 ScrollView 中,或者导入 SwiftSideways

import Tabler
import Sideways

var body: some View {
    TablerList(header: header,
               row: row,
               results: fruits)
        .sideways(minWidth: 400)
}

AutoInit 代码生成

这仅适用于那些 fork 并自定义 Tabler 代码的人员。

每个表格变体的许多附加 init() 函数都是通过代码模板 Templates/AutoInit.stencil 生成的。

要重新生成和重新格式化,请从项目目录运行 Sourcery 命令。

$ brew install sourcery
$ sourcery
$ brew install swiftformat
$ swiftformat **/*.swift   

生成的代码将在 Sources/Generated 目录中找到。

另请参阅

演示 Tabler 的应用

此库是 OpenAlloc Project 的成员。

许可证

版权所有 2021, 2022 OpenAlloc LLC

根据 Apache License, Version 2.0(“许可证”)获得许可; 除非遵守许可证,否则您不得使用此文件。 您可以在以下位置获得许可证副本:

https://apache.ac.cn/licenses/LICENSE-2.0

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

贡献

欢迎贡献。 鼓励您提交 pull request 以修复错误、改进文档或提供新功能。

pull request 不需要是生产就绪的功能或修复。 它可以是拟议更改的草稿,或者只是一个测试,以表明预期的行为存在错误。 关于 pull request 的讨论可以从那里开始。