Table 是 SwiftUI 3.0 中为 macOS 渠道供给的表格控件,开发者经过它可以快捷地创立可交互的多列表格。在 WWDC 2022 中,Table 被拓展到 iPadOS 渠道,让其具有了更大的发挥空间。本文将介绍 Table 的用法、剖析 Table 的特点以及怎么在其他的渠道上实现相似的功能。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

具有列( Row )特征的 List

在 Table 的界说中,具有明晰的行( Row )与列( Column )的概念。但相较于 SwiftUI 中的网格容器( LazyVGrid、Grid )来说,Table 本质上更接近于 List 。开发者可以将 Table 视为具有列特征的 List 。

用 Table 在 SwiftUI 下创建表格

上图是咱们运用 List 创立一个有关 Locale 信息的表格,每行都显现一个与 Locale 有关的数据。创立代码如下:

struct LocaleInfoList: View {
    @State var localeInfos: [LocaleInfo] = []
    let titles = ["标识符", "言语", "价格", "钱银代码", "钱银符号"]
    var body: some View {
        List {
            HStack {
                ForEach(titles, id: \.self) { title in
                    Text(title)
                        .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    Divider()
                }
            }
            ForEach(localeInfos) { localeInfo in
                HStack {
                    Group {
                        Text(localeInfo.identifier)
                        Text(localeInfo.language)
                        Text(localeInfo.price.formatted())
                            .foregroundColor(localeInfo.price > 4 ? .red : .green)
                        Text(localeInfo.currencyCode)
                        Text(localeInfo.currencySymbol)
                    }
                    .lineLimit(1)
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                }
            }
        }
        .task {
            localeInfos = prepareData()
        }
    }
}
struct LocaleInfo: Identifiable, Hashable {
    var id: String {
        identifier
    }
    let identifier: String
    let language: String
    let currencyCode: String
    let currencySymbol: String
    let price: Int = .random(in: 3...6)
    let updateDate = Date.now.addingTimeInterval(.random(in: -100000...100000))
    var supported: Bool = .random()
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}
// 生成演示数据
func prepareData() -> [LocaleInfo] {
    Locale.availableIdentifiers
        .map {
            let cnLocale = Locale(identifier: "zh-cn")
            let locale = Locale(identifier: $0)
            return LocaleInfo(
                identifier: $0,
                language: cnLocale.localizedString(forIdentifier: $0) ?? "",
                currencyCode: locale.currencyCode ?? "",
                currencySymbol: locale.currencySymbol ?? ""
            )
        }
        .filter {
            !($0.currencySymbol.isEmpty || $0.currencySymbol.isEmpty || $0.currencyCode.isEmpty)
        }
}

下面的是运用 Table 创立相同表格的代码:

struct TableDemo: View {
    @State var localeInfos = [LocaleInfo]()
    var body: some View {
        Table {
            TableColumn("标识符", value: \.identifier)
            TableColumn("言语", value: \.language)
            TableColumn("价格") {
                Text("\($0.price)")
                    .foregroundColor($0.price > 4 ? .red : .green)
            }
            TableColumn("钱银代码", value: \.currencyCode)
            TableColumn("钱银符号", value: \.currencySymbol)
        } rows: {
            ForEach(localeInfos) {
                TableRow($0)
            }
        }
        .task {
            localeInfos = prepareData()
        }
    }
}

用 Table 在 SwiftUI 下创建表格

相较于 List 的版别,不只代码量更少、表述更加明晰,而且咱们还可以获得可固定的标题栏。同 List 相同,Table 也具有直接引证数据的结构办法,上面的代码还可以进一步地简化为:

struct TableDemo: View {
    @State var localeInfos = [LocaleInfo]()
    var body: some View {
        Table(localeInfos) { // 直接引证数据源
            TableColumn("标识符", value: \.identifier)
            TableColumn("言语", value: \.language)
            TableColumn("价格") {
                Text("\($0.price)")
                    .foregroundColor($0.price > 4 ? .red : .green)
            }
            TableColumn("钱银代码", value: \.currencyCode)
            TableColumn("钱银符号", value: \.currencySymbol)
        }
        .task {
            localeInfos = prepareData()
        }
    }
}

在 SwiftUI 4.0 的榜首个测验版别中( Xcode 14.0 beta (14A5228q) ),Table 在 iPad OS 上的体现欠安,存在不少的 Bug 。例如:标题行与数据行( 首行 )堆叠;标题行榜首列不显现;翻滚不顺畅以及某些体现( 行高 )与 macOS 版别不共同等状况。

Table 与 List 的近似点:

  • 声明逻辑接近
  • 与 LazyVGrid( LazyHGrid )和 Grid 倾向于将数据元素放置于一个单元格( Cell )中不同,在 Table 与 List 中,更习惯于将数据元素以行( Row )的形式进行展示( 在一行中显现数据的不同特点内容 )
  • 在 Table 中数据是懒加载的,行视图( TableColumn )的 onAppear 和 onDisappear 的行为也与 List 共同
  • Table 与 List 并非真实含义上的布局容器,它们并不像 LazyVGrid、Grid、VStack 等布局容器那样支撑视图烘托功能( ImageRenderer )

列宽与行高

列宽

在 Table 中,咱们可以在列设定中设置列宽:

Table(localeInfos) {
    TableColumn("标识符", value: \.identifier)
    TableColumn("言语", value: \.language)
        .width(min: 200, max: 300)  // 设置宽度范围
    TableColumn("价格") {
        Text("\($0.price)")
            .foregroundColor($0.price > 4 ? .red : .green)
    }
    .width(50) // 设置详细宽度
    TableColumn("钱银代码", value: \.currencyCode)
    TableColumn("钱银符号", value: \.currencySymbol)
}

用 Table 在 SwiftUI 下创建表格

其他未指定列宽的列( 标识符、钱银代码、钱银符号),将会依据 Table 中剩余的横向尺寸进行平分。在 macOS 上,运用者可以经过鼠标拖动列距离线来改动列间距。

与 List 相同,Table 内置了纵向的翻滚支撑。在 macOS 上,假如 Table 中的内容( 行宽度 )超过了 Table 的宽度,Table 将主动开启横向翻滚支撑。

假如数据量较小可以完好展示,开发者可以运用 scrollDisabled(true) 屏蔽内置的翻滚支撑。

行高

在 macOS 下,Table 的行高是锁定的。无论单元格中内容的实践高度需求有多大,Table 始终将坚持体系给定的默许行高。

TableColumn("价格") {
    Text("\($0.price)")
        .foregroundColor($0.price > 4 ? .red : .green)
        .font(.system(size: 64))
        .frame(height:100)

用 Table 在 SwiftUI 下创建表格

在 iPadOS 下,Table 将依据单元格的高度,主动调整行高。

用 Table 在 SwiftUI 下创建表格

现在无法确定这种状况是有意的设计仍是 Bug

距离与对齐

因为 Table 并非真实含义上的网格布局容器,因此并没有供给队伍距离或队伍对齐方面的设定。

开发者可以经过 frame 修饰符来更改单元格中内容的对齐办法( 暂时无法更改标题的对齐办法 ):

TableColumn("钱银代码") {
    Text($0.currencyCode)
        .frame(minWidth: 0, maxWidth: .infinity, alignment: .trailing)
}

用 Table 在 SwiftUI 下创建表格

在 Table 中,假如该列显现的特点类型为 String,且无须增加其他设定,可以运用根据 KeyPath 的精简写法:

TableColumn("钱银代码", value:\.currencyCode)

但是,假如特点类型不为 String,或者需求增加其他的设定( 字体、色彩等 ),只能选用跟随闭包的办法来界说 TableColumn ( 如上方的钱银代码 )。

款式

SwiftUI 为 Table 供给了几种款式挑选,惋惜的是现在只有 .inset 可以用于 iPadOS 。

Table(localeInfos) {
   // 界说 TableColumn ...
}
.tableStyle(.inset(alternatesRowBackgrounds:false))
  • inset

    默许款式( 本文之前的截图均为 inset 款式 ),可用于 macOS 和 iPadOS。在 mac 下等同于 inset(alternatesRowBackgrounds: true) ,在 iPadOS 下等同于 inset(alternatesRowBackgrounds: false)

  • inset(alternatesRowBackgrounds: Bool)

    仅用于 macOS,可以设置是否开启行交织布景,便于视觉区分

  • bordered

    仅用于 macOS,为 Table 增加边框

用 Table 在 SwiftUI 下创建表格

  • bordered(alternatesRowBackgrounds: Bool)

    仅用于 macOS,可以设置是否开启行交织布景,便于视觉区分

或许在之后的测验版中,SwiftUI 会扩展更多的款式到 iPadOS 渠道

行挑选

在 Table 中启用行挑选与 List 中的办法非常相似:

struct TableDemo: View {
    @State var localeInfos = [LocaleInfo]()
    @State var selection: String?
    var body: some View {
        Table(localeInfos, selection: $selection) {
           // 界说 TableColumn ...
        }
    }
}

需求留意的是,Table 要求绑定的变量类型与数据( 数据需求遵从 Identifier 协议 )的 id 类型共同。比方本例中,LocaleInfo 的 id 类型为 String。

@State var selection: String?  // 单选
@State var selections: Set<String> = []  // 多选,需求 LocaleInfo 遵从 Hashable 协议

下图为开启多选后的场景:

用 Table 在 SwiftUI 下创建表格

排序

Table 另一大核心功能是可以高效地实现多特点排序。

struct TableDemo: View {
    @State var localeInfos = [LocaleInfo]()
    @State var order: [KeyPathComparator<LocaleInfo>] = [.init(\.identifier, order: .forward)] // 排序条件
    var body: some View {
        Table(localeInfos, sortOrder: $order) { // 绑定排序条件
            TableColumn("标识符", value: \.identifier)
            TableColumn("言语", value: \.language)
                .width(min: 200, max: 300)
            TableColumn("价格",value: \.price) {
                Text("\($0.price)")
                    .foregroundColor($0.price > 4 ? .red : .green)
            }
            .width(50)
            TableColumn("钱银代码", value: \.currencyCode)
            TableColumn("钱银符号", value: \.currencySymbol)
        }
        .onChange(of: order) { newOrder in
            withAnimation {
                localeInfos.sort(using: newOrder) // 排序条件改动时对数据重排序
            }
        }
        .task {
            localeInfos = prepareData()
            localeInfos.sort(using: order) // 初始化排序
        }
        .scenePadding()
    }
}

用 Table 在 SwiftUI 下创建表格

Table 本身并不会修正数据源,当 Table 绑定了排序变量后,点击支撑排序的列标题,Table 会主动更改排序变量的内容。开发者仍需监控排序变量的变化进行排序。

Table 要求排序变量的类型为遵从 SortComparator 的数组,本例中咱们直接运用了 Swift 供给的 KeyPathComparator 类型。

假如不想让某个列支撑排序,只需求不运用含有 value 参数的 TableColumn 结构办法即可,例如:

TableColumn("钱银代码", value: \.currencyCode) // 启用以该特点为依据的排序
TableColumn("钱银代码"){ Text($0.currencyCode) } // 不启用以该特点为依据的排序
// 切勿在不绑定排序变量时,运用如下的写法。运用程序将无法编译( 而且简直不会获得过错提示 )
TableColumn("价格",value: \.currencyCode) {
    Text("\($0.price)")
        .foregroundColor($0.price > 4 ? .red : .green)
}

现在的测验版 14A5228q ,当特点类型为 Bool 时,在该列上启用排序会导致运用无法编译

虽然在点击可排序列标题后,仅有一个列标题显现了排序方向,但事实上 Table 将依照用户的点击次序增加或整理排序变量的排序次序。下面的代码可以明晰地体现这一点:

struct TableDemo: View {
    @State var localeInfos = [LocaleInfo]()
    @State var order: [KeyPathComparator<LocaleInfo>] = [.init(\.identifier, order: .forward)]
    var body: some View {
        VStack {
            sortKeyPathView() // 显现当时的排序次序
                .frame(minWidth: 0, maxWidth: .infinity, alignment: .trailing)
            Table(localeInfos, sortOrder: $order) {
                TableColumn("标识符", value: \.identifier)
                TableColumn("言语", value: \.language)
                    .width(min: 200, max: 300)
                TableColumn("价格", value: \.price) {
                    Text("\($0.price)")
                        .foregroundColor($0.price > 4 ? .red : .green)
                }
                .width(50)
                TableColumn("钱银代码", value: \.currencyCode)
                TableColumn("钱银符号", value: \.currencySymbol)
            }
        }
        .onChange(of: order) { newOrder in
            withAnimation {
                localeInfos.sort(using: newOrder)
            }
        }
        .task {
            localeInfos = prepareData()
            localeInfos.sort(using: order)
        }
        .scenePadding()
    }
    func sortKeyPath() -> [String] {
        order
            .map {
                let keyPath = $0.keyPath
                let sortOrder = $0.order
                var keyPathString = ""
                switch keyPath {
                case \LocaleInfo.identifier:
                    keyPathString = "标识符"
                case \LocaleInfo.language:
                    keyPathString = "言语"
                case \LocaleInfo.price:
                    keyPathString = "价格"
                case \LocaleInfo.currencyCode:
                    keyPathString = "钱银代码"
                case \LocaleInfo.currencySymbol:
                    keyPathString = "钱银符号"
                case \LocaleInfo.supported:
                    keyPathString = "已支撑"
                case \LocaleInfo.updateDate:
                    keyPathString = "日期"
                default:
                    break
                }
                return keyPathString + (sortOrder == .reverse ? "↓" : "↑")
            }
    }
    @ViewBuilder
    func sortKeyPathView() -> some View {
        HStack {
            ForEach(sortKeyPath(), id: \.self) { sortKeyPath in
                Text(sortKeyPath)
            }
        }
    }
}

用 Table 在 SwiftUI 下创建表格

假如担心根据多特点的排序办法有功能方面的问题( 在数据量很大时 ),可以只运用最终创立的排序条件:

.onChange(of: order) { newOrder in
    if let singleOrder = newOrder.first {
        withAnimation {
            localeInfos.sort(using: singleOrder)
        }
    }
}

在将 SortComparator 转换成 SortDescription( 或 NSSortDescription ) 用于 Core Data 时,请不要运用 Core Data 无法支撑的 Compare 算法

拖拽

Table 支撑以行为单位进行 Drag&Drop 。启用 Drag 支撑时,将无法运用 Table 的简化版界说:

Table {
    TableColumn("标识符", value: \.identifier)
    TableColumn("言语", value: \.language)
        .width(min: 200, max: 300)
    TableColumn("价格", value: \.price) {
        Text("\($0.price)")
            .foregroundColor($0.price > 4 ? .red : .green)
    }
    .width(50)
    TableColumn("钱银代码", value: \.currencyCode)
    TableColumn("钱银符号", value: \.currencySymbol)
} rows: {
    ForEach(localeInfos){ localeInfo in
        TableRow(localeInfo)
            .itemProvider {  // enable Drap 
                NSItemProvider(object: localeInfo.identifier as NSString)
            }
    }
}

用 Table 在 SwiftUI 下创建表格

交互

除了行挑选和行拖拽外,Table 还支撑对行设置上下文菜单( macOS 13+、iPadOS 16+ ):

ForEach(localeInfos) { localeInfo in
    TableRow(localeInfo)
        .contextMenu{
            Button("修改"){}
            Button("删去"){}
            Button("同享"){}
        }
}

用 Table 在 SwiftUI 下创建表格

创立可交互的单元格,将极大地提升表格的用户体验。

struct TableDemo: View {
    @State var localeInfos = [LocaleInfo]()
    var body: some View {
        VStack {
            Table(localeInfos) {
                TableColumn("标识符", value: \.identifier)
                TableColumn("言语", value: \.language)
                    .width(min: 200, max: 300)
                TableColumn("价格") {
                    Text("\($0.price)")
                        .foregroundColor($0.price > 4 ? .red : .green)
                }
                .width(50)
                TableColumn("钱银代码", value: \.currencyCode)
                TableColumn("钱银符号", value: \.currencySymbol)
                TableColumn("已支撑") {
                    supportedToggle(identifier: $0.identifier, supported: $0.supported)
                }
            }
        }
        .lineLimit(1)
        .task {
            localeInfos = prepareData()
        }
        .scenePadding()
    }
    @ViewBuilder
    func supportedToggle(identifier: String, supported: Bool) -> some View {
        let binding = Binding<Bool>(
            get: { supported },
            set: {
                if let id = localeInfos.firstIndex(where: { $0.identifier == identifier }) {
                    self.localeInfos[id].supported = $0
                }
            }
        )
        Toggle(isOn: binding, label: { Text("") })
    }
}

用 Table 在 SwiftUI 下创建表格

前驱仍是先烈?

假如你在 Xcode 中编写运用 Table 的代码,大概率会碰到主动提示无法作业的状况。甚至还会呈现运用程序无法编译,但没有明晰的过错提示( 过错发生在 Table 内部)。

呈现上述问题的首要原因是,苹果没有选用其他 SwiftUI 控件常用的编写办法( 原生的 SwiftUI 容器或包装 UIKit 控件),开创性地运用了 result builder 为 Table 编写了自己的 DSL 。

或许因为 Table 的 DSL 功率欠安的原因( 过多的泛型、过多的结构办法、一个 Table 中有两个 Builder ),当时版别的 Xcode 在处理 Table 代码时相当吃力。

另外,因为 Table DSL 的界说并不完好( 短少相似 Group 的容器 ),现在至多只能支撑十列数据( 原因请参阅 ViewBuilder 研究(下) —— 从仿照中学习 )。

或许苹果是吸取了 Table DSL 的教训,WWDC 2022 中推出的 SwiftUI Charts( 也是根据 result builder )在 Xcode 下的功能体现明显地好于 Table 。

期望苹果能将 Charts 中获取的经历反哺给 Table ,防止让前驱变成了先烈。

在其他渠道上创立表格

虽然 Table 可以在依照 iOS 16 的 iPhone 上运转,但因为只能显现首列数据,因此并不具有实践的含义。

假如想在 Table 尚不支撑或支撑不完善的渠道(比方 iPhone)上实现表格功能,请依据你的需求挑选合适的替代方案:

  • 数据量较大,需求懒加载

    List、LazyVGrid

  • 根据行的交互操作( 拖拽、上下文菜单、挑选 )

    List( Grid 中的 GridRow 并非真实含义上的行 )

  • 需求视图可烘托( 保存成图片 )

    LazyVGrid、Grid

  • 可固定的标题行

    List、LazyVGrid、Grid( 比方运用 matchedGeometryEffect )

总结

假如你想在 SwiftUI 中用更少的代码、更明晰的表达办法创立可交互的表格,不妨试试 Table 。一起也期望苹果能在接下来的版别中改善 Table 在 Xcode 中的开发功率,并为 Table 增加更多的原生功能。

期望本文可以对你有所协助。

原文发表在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】