用 Table 在 SwiftUI 下建立表格

語言: CN / TW / HK

highlight: a11y-dark

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 。

image-20220620142551830

上圖是我們使用 List 建立一個有關 Locale 資訊的表格,每行都顯示一個與 Locale 有關的資料。建立程式碼如下:

```swift 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 建立同樣表格的程式碼:

swift 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() } } }

image-20220620142510240

相較於 List 的版本,不僅程式碼量更少、表述更加清晰,而且我們還可以獲得可固定的標題欄。同 List 一樣,Table 也擁有直接引用資料的構造方法,上面的程式碼還可以進一步地簡化為:

swift 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 中,我們可以在列設定中設定列寬:

swift 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) }

image-20220620150114288

其他未指定列寬的列( 識別符號、貨幣程式碼、貨幣符號),將會根據 Table 中剩餘的橫向尺寸進行平分。在 macOS 上,使用者可以通過滑鼠拖動列間隔線來改變列間距。

與 List 一樣,Table 內建了縱向的滾動支援。在 macOS 上,如果 Table 中的內容( 行寬度 )超過了 Table 的寬度,Table 將自動開啟橫向滾動支援。

如果資料量較小能夠完整展示,開發者可以使用 scrollDisabled(true) 遮蔽內建的滾動支援。

行高

在 macOS 下,Table 的行高是鎖定的。無論單元格中內容的實際高度需求有多大,Table 始終將保持系統給定的預設行高。

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

image-20220620181736770

在 iPadOS 下,Table 將根據單元格的高度,自動調整行高。

image-20220620181923446

目前無法確定這種情況是有意的設計還是 Bug

間隔與對齊

由於 Table 並非真正意義上的網格佈局容器,因此並沒有提供行列間隔或行列對齊方面的設定。

開發者可以通過 frame 修飾符來更改單元格中內容的對齊方式( 暫時無法更改標題的對齊方式 ):

swift TableColumn("貨幣程式碼") { Text($0.currencyCode) .frame(minWidth: 0, maxWidth: .infinity, alignment: .trailing) }

image-20220620182615838

在 Table 中,如果該列顯示的屬性型別為 String,且無須新增其他設定,可以使用基於 KeyPath 的精簡寫法:

swift TableColumn("貨幣程式碼", value:\.currencyCode)

但是,如果屬性型別不為 String,或者需要新增其他的設定( 字型、顏色等 ),只能採用尾隨閉包的方式來定義 TableColumn ( 如上方的貨幣程式碼 )。

樣式

SwiftUI 為 Table 提供了幾種樣式選擇,遺憾的是目前只有 .inset 可以用於 iPadOS 。

swift 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 新增邊框

image-20220620183823794

  • bordered(alternatesRowBackgrounds: Bool)

僅用於 macOS,可以設定是否開啟行交錯背景,便於視覺區分

或許在之後的測試版中,SwiftUI 會擴充套件更多的樣式到 iPadOS 平臺

行選擇

在 Table 中啟用行選擇與 List 中的方式十分類似:

swift 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。

swift @State var selection: String? // 單選 @State var selections: Set<String> = [] // 多選,需要 LocaleInfo 遵循 Hashable 協議

下圖為開啟多選後的場景:

image-20220620184638673

排序

Table 另一大核心功能是可以高效地實現多屬性排序。

swift 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_sort_demo1_2022-06-20_18.55.16.2022-06-20 18_57_13

Table 本身並不會修改資料來源,當 Table 綁定了排序變數後,點選支援排序的列標題,Table 會自動更改排序變數的內容。開發者仍需監控排序變數的變化進行排序。

Table 要求排序變數的型別為遵循 SortComparator 的陣列,本例中我們直接使用了 Swift 提供的 KeyPathComparator 型別。

如果不想讓某個列支援排序,只需要不使用含有 value 引數的 TableColumn 構造方法即可,例如:

```swift TableColumn("貨幣程式碼", value: .currencyCode) // 啟用以該屬性為依據的排序 TableColumn("貨幣程式碼"){ Text($0.currencyCode) } // 不啟用以該屬性為依據的排序

// 切勿在不繫結排序變數時,使用如下的寫法。應用程式將無法編譯( 並且幾乎不會獲得錯誤提示 ) TableColumn("價格",value: .currencyCode) { Text("($0.price)") .foregroundColor($0.price > 4 ? .red : .green) } ```

目前的測試版 14A5228q ,當屬性型別為 Bool 時,在該列上啟用排序會導致應用無法編譯

儘管在點選可排序列標題後,僅有一個列標題顯示了排序方向,但事實上 Table 將按照使用者的點選順序新增或整理排序變數的排序順序。下面的程式碼可以清晰地體現這一點:

```swift struct TableDemo: View { @State var localeInfos = LocaleInfo @State var order: [KeyPathComparator] = [.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_sort_demo2_2022-06-20_19.11.48.2022-06-20 19_13_16

如果擔心基於多屬性的排序方式有效能方面的問題( 在資料量很大時 ),可以只使用最後建立的排序條件:

swift .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 的簡化版定義:

swift 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_drag_demo_2022-06-20_19.36.09.2022-06-20 19_37_28

互動

除了行選擇和行拖拽外,Table 還支援對行設定上下文選單( macOS 13+、iPadOS 16+ ):

swift ForEach(localeInfos) { localeInfo in TableRow(localeInfo) .contextMenu{ Button("編輯"){} Button("刪除"){} Button("共享"){} } }

image-20220620194057400

建立可互動的單元格,將極大地提升表格的使用者體驗。

```swift 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("") })
}

} ```

image-20220620194359218

先驅還是先烈?

如果你在 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記事本】