SwiftUI

表格

深入理解 SwiftUI 的 Table 组件,掌握创建可选择、可排序的多列数据表格

SwiftUI 的 Table 组件提供了一种强大的方式来展示结构化的多列数据,支持选择、排序、自定义列等功能。本文详细介绍 SwiftUI 表格的核心概念和使用方法。

概述

Table 是一个容器视图,用于以行列形式展示数据集合。每个集合元素对应表格中的一行,每个属性值对应不同的列。

主要特性

  • 自动提供垂直滚动(macOS 上还支持水平滚动)
  • 支持单选和多选
  • 支持列排序
  • 支持列自定义(重排序、显示/隐藏)
  • 支持层级数据展示
  • 在窄屏幕上自动适配为类似 List 的外观

平台支持

  • iOS 16.0+
  • iPadOS 16.0+
  • macOS 12.0+
  • Mac Catalyst 16.0+
  • visionOS 1.0+

在 iOS 上,当 horizontalSizeClasscompact 时,表格可能只显示第一列,其他列会被隐藏。

创建基础表格

简单表格示例

首先定义数据模型:

struct Person: Identifiable {
    let givenName: String
    let familyName: String
    let emailAddress: String
    let id = UUID()
    
    var fullName: String { givenName + " " + familyName }
}

创建表格:

struct PeopleTable: View {
    @State private var people = [
        Person(givenName: "Juan", familyName: "Chavez", emailAddress: "juanchavez@icloud.com"),
        Person(givenName: "Mei", familyName: "Chen", emailAddress: "meichen@icloud.com"),
        Person(givenName: "Tom", familyName: "Clark", emailAddress: "tomclark@icloud.com"),
        Person(givenName: "Gita", familyName: "Kumar", emailAddress: "gitakumar@icloud.com")
    ]
    
    var body: some View {
        Table(people) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail Address", value: \.emailAddress)
        }
    }
}

这个示例创建了一个包含三列的表格,每列显示 Person 的一个属性。

表格列 (TableColumn)

TableColumn 定义了表格中的一列,包括列标题、数据来源和显示方式。

不可排序的列

最简单的列定义,仅用于显示:

TableColumn("Name") { person in
    Text(person.fullName)
        .font(.headline)
}

可排序的列

使用 value 参数指定用于排序的 KeyPath:

// String 属性的便捷形式
TableColumn("Given Name", value: \.givenName)

// 自定义内容视图
TableColumn("Given Name", value: \.givenName) { person in
    Text(person.givenName)
        .foregroundColor(.blue)
}

自定义排序比较器

对于非标准类型或自定义排序逻辑:

struct Report: Identifiable {
    let id = UUID()
    let title: String
    let priority: Priority
    
    enum Priority: Comparable {
        case low, medium, high
    }
}

TableColumn("Priority", value: \.priority, comparator: { lhs, rhs in
    lhs.rawValue < rhs.rawValue
}) { report in
    PriorityBadge(priority: report.priority)
}

列宽度控制

固定宽度

TableColumn("ID", value: \.id)
    .width(80)

可调整宽度

TableColumn("Description", value: \.description)
    .width(min: 100, ideal: 200, max: 400)

列对齐

TableColumn("Amount", value: \.amount) { item in
    Text(item.amount, format: .currency(code: "USD"))
}
.alignment(.trailing)

选择功能

单选

绑定到单个可选的 ID 值:

struct SelectableTable: View {
    @State private var people = [/* ... */]
    @State private var selectedPerson: Person.ID?
    
    var body: some View {
        Table(people, selection: $selectedPerson) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail", value: \.emailAddress)
        }
    }
}

多选

绑定到 Set<ID>

struct MultiSelectTable: View {
    @State private var people = [/* ... */]
    @State private var selectedPeople: Set<Person.ID> = []
    
    var body: some View {
        VStack {
            Table(people, selection: $selectedPeople) {
                TableColumn("Given Name", value: \.givenName)
                TableColumn("Family Name", value: \.familyName)
                TableColumn("E-Mail", value: \.emailAddress)
            }
            
            Text("已选择 \(selectedPeople.count) 人")
                .padding()
        }
    }
}

排序功能

基础排序

使用 sortOrder 绑定启用排序:

struct SortableTable: View {
    @State private var people = [/* ... */]
    @State private var sortOrder = [KeyPathComparator(\Person.givenName)]
    
    var body: some View {
        Table(people, sortOrder: $sortOrder) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail", value: \.emailAddress)
        }
        .onChange(of: sortOrder) { oldValue, newValue in
            people.sort(using: newValue)
        }
    }
}

多列排序

用户可以通过按住 Shift 键点击列标题来添加次要排序条件:

@State private var sortOrder = [
    KeyPathComparator(\Person.familyName),
    KeyPathComparator(\Person.givenName)
]

自定义排序比较器

struct CustomSortTable: View {
    @State private var items = [/* ... */]
    @State private var sortOrder = [KeyPathComparator(\Item.date)]
    
    var body: some View {
        Table(items, sortOrder: $sortOrder) {
            TableColumn("Name", value: \.name)
            
            TableColumn("Date", sortUsing: KeyPathComparator(\.date)) { item in
                Text(item.date, style: .date)
            }
            
            TableColumn("Status", value: \.status, comparator: StatusComparator())
        }
        .onChange(of: sortOrder) { _, newOrder in
            items.sort(using: newOrder)
        }
    }
}

struct StatusComparator: SortComparator {
    typealias Compared = Status
    
    func compare(_ lhs: Status, _ rhs: Status) -> ComparisonResult {
        // 自定义比较逻辑
        lhs.priority.compare(to: rhs.priority)
    }
}

列自定义 (TableColumnCustomization)

从 iOS 17+ / macOS 14+ 开始,表格支持列的重排序和显示/隐藏。

启用列自定义

struct CustomizableTable: View {
    @State private var bugReports = [/* ... */]
    @State private var selectedReports: Set<BugReport.ID> = []
    @State private var sortOrder = [KeyPathComparator(\BugReport.title)]
    
    @SceneStorage("BugReportTableConfig")
    private var columnCustomization: TableColumnCustomization<BugReport>
    
    var body: some View {
        Table(
            bugReports,
            selection: $selectedReports,
            sortOrder: $sortOrder,
            columnCustomization: $columnCustomization
        ) {
            TableColumn("Title", value: \.title)
                .customizationID("title")
            
            TableColumn("ID", value: \.id) { report in
                Link("\(report.id)", destination: report.url)
            }
            .customizationID("id")
            
            TableColumn("Reports", value: \.duplicateCount) { report in
                Text(report.duplicateCount, format: .number)
            }
            .customizationID("duplicates")
        }
        .onChange(of: sortOrder) { _, newOrder in
            bugReports.sort(using: newOrder)
        }
    }
}

编程方式控制列可见性

// 隐藏某一列
columnCustomization[visibility: "duplicates"] = .hidden

// 显示某一列
columnCustomization[visibility: "duplicates"] = .visible

// 自动(使用默认可见性)
columnCustomization[visibility: "duplicates"] = .automatic

绑定到列可见性

struct ColumnVisibilityControl: View {
    @SceneStorage("TableConfig")
    private var columnCustomization: TableColumnCustomization<Item>
    
    var body: some View {
        VStack {
            // 表格
            Table(/* ... */, columnCustomization: $columnCustomization) {
                // 列定义
            }
            
            // 可见性控制
            Toggle(
                "显示重复数量",
                isOn: Binding(
                    get: { columnCustomization[visibility: "duplicates"] == .visible },
                    set: { columnCustomization[visibility: "duplicates"] = $0 ? .visible : .hidden }
                )
            )
        }
    }
}

持久化配置

使用 @SceneStorage@AppStorage 保存列配置:

// 场景级别持久化
@SceneStorage("MyTableConfig")
private var columnCustomization: TableColumnCustomization<MyData>

// 应用级别持久化
@AppStorage("MyTableConfig")
private var columnCustomization: TableColumnCustomization<MyData>

每个可自定义的列必须设置唯一的 customizationID,否则该列将不可自定义。

表格行

TableRow

TableRow 用于在更复杂的场景中显式定义行:

Table(of: Person.self) {
    TableColumn("Name", value: \.fullName)
    TableColumn("Email", value: \.emailAddress)
} rows: {
    ForEach(people) { person in
        TableRow(person)
    }
}

DisclosureTableRow

用于创建可展开/折叠的层级表格(iOS 17+ / macOS 14+):

struct FileItem: Identifiable {
    let id = UUID()
    let name: String
    let size: Int64
    var children: [FileItem]?
}

struct HierarchicalTable: View {
    @State private var files = [/* ... */]
    @State private var expandedItems: Set<FileItem.ID> = []
    
    var body: some View {
        Table(of: FileItem.self) {
            TableColumn("Name", value: \.name)
            TableColumn("Size") { file in
                Text(file.size, format: .byteCount(style: .file))
            }
        } rows: {
            ForEach(files) { file in
                buildRow(for: file)
            }
        }
    }
    
    @ViewBuilder
    private func buildRow(for file: FileItem) -> some View {
        if let children = file.children {
            DisclosureTableRow(
                file,
                isExpanded: Binding(
                    get: { expandedItems.contains(file.id) },
                    set: { isExpanded in
                        if isExpanded {
                            expandedItems.insert(file.id)
                        } else {
                            expandedItems.remove(file.id)
                        }
                    }
                )
            ) {
                ForEach(children) { child in
                    buildRow(for: child)
                }
            }
        } else {
            TableRow(file)
        }
    }
}

层级表格

使用 children 参数创建层级表格:

struct FolderItem: Identifiable {
    let id = UUID()
    let name: String
    let size: Int64
    var children: [FolderItem]?
}

struct HierarchicalFolderTable: View {
    @State private var folders: [FolderItem] = [
        FolderItem(name: "Documents", size: 1024, children: [
            FolderItem(name: "Work", size: 512, children: nil),
            FolderItem(name: "Personal", size: 512, children: nil)
        ]),
        FolderItem(name: "Downloads", size: 2048, children: nil)
    ]
    
    var body: some View {
        Table(folders, children: \.children) {
            TableColumn("Name", value: \.name)
            
            TableColumn("Size") { folder in
                Text(folder.size, format: .byteCount(style: .file))
            }
        }
    }
}

表格样式 (TableStyle)

内置样式

automatic

默认样式,根据上下文自动选择:

Table(people) {
    // 列定义
}
.tableStyle(.automatic)

inset

内容和选择背景内嵌的样式:

Table(people) {
    // 列定义
}
.tableStyle(.inset)

bordered

带标准边框的样式(仅 macOS):

Table(people) {
    // 列定义
}
.tableStyle(.bordered)

自定义表格样式

实现 TableStyle 协议创建自定义样式:

struct CustomTableStyle: TableStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.content
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
            .padding()
    }
}

extension TableStyle where Self == CustomTableStyle {
    static var custom: CustomTableStyle { CustomTableStyle() }
}

// 使用
Table(people) {
    // 列定义
}
.tableStyle(.custom)

紧凑布局适配

在 iOS 上,表格会根据水平尺寸类别自动调整显示:

struct AdaptiveTable: View {
    #if os(iOS)
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
    private var isCompact: Bool { horizontalSizeClass == .compact }
    #else
    private let isCompact = false
    #endif
    
    @State private var people = [/* ... */]
    @State private var sortOrder = [KeyPathComparator(\Person.givenName)]
    
    var body: some View {
        Table(people, sortOrder: $sortOrder) {
            TableColumn("Given Name", value: \.givenName) { person in
                VStack(alignment: .leading) {
                    Text(isCompact ? person.fullName : person.givenName)
                    if isCompact {
                        Text(person.emailAddress)
                            .foregroundStyle(.secondary)
                            .font(.caption)
                    }
                }
            }
            
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail", value: \.emailAddress)
        }
        .onChange(of: sortOrder) { _, newOrder in
            people.sort(using: newOrder)
        }
    }
}

这种方式在紧凑模式下将所有信息合并到第一列,提供类似列表的外观。

实践示例

完整的任务管理表格

struct Task: Identifiable {
    let id = UUID()
    var title: String
    var priority: Priority
    var dueDate: Date
    var isCompleted: Bool
    
    enum Priority: String, Comparable, CaseIterable {
        case low = "低"
        case medium = "中"
        case high = "高"
        
        static func < (lhs: Priority, rhs: Priority) -> Bool {
            let order: [Priority] = [.low, .medium, .high]
            return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)!
        }
    }
}

struct TaskTableView: View {
    @State private var tasks = [
        Task(title: "完成报告", priority: .high, dueDate: Date(), isCompleted: false),
        Task(title: "回复邮件", priority: .medium, dueDate: Date().addingTimeInterval(86400), isCompleted: false),
        Task(title: "整理文档", priority: .low, dueDate: Date().addingTimeInterval(172800), isCompleted: true)
    ]
    
    @State private var selectedTasks: Set<Task.ID> = []
    @State private var sortOrder = [KeyPathComparator(\Task.dueDate)]
    
    @SceneStorage("TaskTableConfig")
    private var columnCustomization: TableColumnCustomization<Task>
    
    var body: some View {
        VStack {
            Table(
                tasks,
                selection: $selectedTasks,
                sortOrder: $sortOrder,
                columnCustomization: $columnCustomization
            ) {
                TableColumn("标题", value: \.title)
                    .customizationID("title")
                
                TableColumn("优先级", value: \.priority) { task in
                    HStack {
                        Circle()
                            .fill(priorityColor(task.priority))
                            .frame(width: 8, height: 8)
                        Text(task.priority.rawValue)
                    }
                }
                .customizationID("priority")
                .width(100)
                
                TableColumn("截止日期", value: \.dueDate) { task in
                    Text(task.dueDate, style: .date)
                }
                .customizationID("dueDate")
                .width(120)
                
                TableColumn("状态") { task in
                    Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
                        .foregroundColor(task.isCompleted ? .green : .gray)
                }
                .customizationID("status")
                .width(60)
            }
            .onChange(of: sortOrder) { _, newOrder in
                tasks.sort(using: newOrder)
            }
            
            HStack {
                Text("已选择 \(selectedTasks.count) 个任务")
                Spacer()
                Button("删除选中") {
                    tasks.removeAll { selectedTasks.contains($0.id) }
                    selectedTasks.removeAll()
                }
                .disabled(selectedTasks.isEmpty)
            }
            .padding()
        }
    }
    
    private func priorityColor(_ priority: Task.Priority) -> Color {
        switch priority {
        case .low: return .green
        case .medium: return .orange
        case .high: return .red
        }
    }
}

层级文件浏览器

struct FileNode: Identifiable {
    let id = UUID()
    let name: String
    let size: Int64
    let modifiedDate: Date
    var children: [FileNode]?
    
    var isFolder: Bool { children != nil }
}

struct FileExplorerTable: View {
    @State private var files: [FileNode] = [
        FileNode(
            name: "Documents",
            size: 0,
            modifiedDate: Date(),
            children: [
                FileNode(name: "Report.pdf", size: 1024000, modifiedDate: Date(), children: nil),
                FileNode(name: "Notes.txt", size: 2048, modifiedDate: Date(), children: nil)
            ]
        ),
        FileNode(
            name: "Pictures",
            size: 0,
            modifiedDate: Date(),
            children: [
                FileNode(name: "Photo1.jpg", size: 2048000, modifiedDate: Date(), children: nil),
                FileNode(name: "Photo2.jpg", size: 1536000, modifiedDate: Date(), children: nil)
            ]
        )
    ]
    
    @State private var selectedFiles: Set<FileNode.ID> = []
    @State private var sortOrder = [KeyPathComparator(\FileNode.name)]
    
    var body: some View {
        Table(
            files,
            children: \.children,
            selection: $selectedFiles,
            sortOrder: $sortOrder
        ) {
            TableColumn("名称", value: \.name) { file in
                HStack {
                    Image(systemName: file.isFolder ? "folder.fill" : "doc.fill")
                        .foregroundColor(file.isFolder ? .blue : .gray)
                    Text(file.name)
                }
            }
            
            TableColumn("大小", value: \.size) { file in
                if file.isFolder {
                    Text("--")
                } else {
                    Text(file.size, format: .byteCount(style: .file))
                }
            }
            .width(100)
            
            TableColumn("修改日期", value: \.modifiedDate) { file in
                Text(file.modifiedDate, style: .date)
            }
            .width(120)
        }
        .onChange(of: sortOrder) { _, newOrder in
            sortFiles(&files, using: newOrder)
        }
    }
    
    private func sortFiles(_ files: inout [FileNode], using order: [KeyPathComparator<FileNode>]) {
        files.sort(using: order)
        for index in files.indices {
            if var children = files[index].children {
                sortFiles(&children, using: order)
                files[index].children = children
            }
        }
    }
}

总结

  • Table 提供了强大的多列数据展示能力,支持自动滚动
  • TableColumn 定义列的显示和排序行为,支持自定义宽度和对齐
  • 选择功能支持单选和多选,通过绑定管理选择状态
  • 排序功能使用 KeyPathComparator 实现,支持多列排序
  • TableColumnCustomization 允许用户重排序和显示/隐藏列,配置可持久化
  • DisclosureTableRowchildren 参数支持层级数据展示
  • TableStyle 提供多种内置样式,也可自定义样式
  • 在 iOS 上会根据屏幕尺寸自动适配,紧凑模式下类似 List 的外观
  • 适合展示结构化数据,如文件列表、任务管理、数据报表等场景
在 GitHub 上编辑

上次更新于