Creating tables with Table in SwiftUI

fatbobman ( 东坡肘子)
11 min readMar 23, 2023

Table is a table view control provided by SwiftUI 3.0 for macOS, allowing developers to quickly create interactive multi-column tables. In WWDC 2022, Table was extended to iPadOS, giving it more room to grow. This article will introduce how to use Table, analyze its characteristics, and how to achieve similar functionality on other platforms.

A Chinese version of this post is available here.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

List with row characteristics

In Table’s definition, there is a clear distinction between rows and columns. However, compared to SwiftUI’s grid containers (LazyVGrid, Grid), Table is essentially more similar to List. Developers can view Table as a list with column characteristics.

The above image shows a table created using List that displays data related to locales. Each row displays data related to a locale. The code for creating this list is as follows:

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

The following is the code for creating the same table using 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()
}
}
}

Compared to the List version, the Table version not only has less code and clearer expression, but also provides us with a fixed header. Like List, Table also has a constructor that directly references the data, and the code above can be further simplified as:

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

In the first beta version of SwiftUI 4.0 (Xcode 14.0 beta (14A5228q)), Table performs poorly on iPad OS and has many bugs. For example: the title row overlaps with the data row (the first row); the first column of the title row is not displayed; scrolling is not smooth; and some behaviors (row height) are inconsistent with the macOS version.

Similarities between Table and List:

  • The declaration logic is similar.
  • Unlike LazyVGrid (LazyHGrid) and Grid, which tend to place data elements in a single cell (Cell), Table and List are more accustomed to displaying data elements in rows (Row) (showing different attribute content in a row).
  • In Table, data is lazily loaded, and the behavior of row views (TableColumn) onAppear and onDisappear is consistent with List.
  • Table and List are not true layout containers. They do not support view rendering functions (ImageRenderer) like LazyVGrid, Grid, VStack, and other layout containers.

Column Width and Row Height

Column Width

In Table, we can set the column width in the column settings:

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

Columns (Identifier, Currency Code, and Currency Symbol) that do not have a specified width will be evenly distributed based on the remaining horizontal size of the Table. On macOS, users can drag the column separator line with a mouse to change the column spacing.

Like List, Table has built-in vertical scrolling support. On macOS, if the content (row width) in the Table exceeds the width of the Table, Table will automatically enable horizontal scrolling support.

If the amount of data is small and can be displayed completely, developers can use scrollDisabled(true) to disable the built-in scrolling support.

Row Height

On macOS, the row height of Table is fixed. Regardless of how large the actual height requirements of the content in the cell are, Table will always maintain the default row height given by the system.

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

In iPadOS, Table automatically adjusts the row height based on the height of the cells.

It is currently unclear whether this behavior is an intentional design or a bug.

Spacing and Alignment

Since Table is not a true grid layout container, it does not provide settings for row/column spacing or alignment.

Developers can change the alignment of the content within cells using the frame modifier (although the alignment of the headers cannot be changed at this time):

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

In Table, if the displayed property type of a column is String and no other settings need to be added, you can use the simplified syntax based on KeyPath:

TableColumn("Currency Code", value:\.currencyCode)

However, if the property type is not a String or other settings need to be added (e.g., font, color, etc.), you must use a trailing closure to define the TableColumn (as with the "Currency Code" example above).

Style

SwiftUI provides several style options for Tables, unfortunately, only .inset is available for iPadOS at this time.

struct TableDemo: View {
@State var localeInfos = [LocaleInfo]()
@State var selection: String?
var body: some View {
Table(localeInfos, selection: $selection) {
// Define TableColumns ...
}
}
}
  • inset

The default style (all screenshots before this section use the inset style), available on both macOS and iPadOS. On macOS, it is equivalent to inset(alternatesRowBackgrounds: true), and on iPadOS, it is equivalent to inset(alternatesRowBackgrounds: false).

  • inset(alternatesRowBackgrounds: Bool)

Only available on macOS, it allows setting whether to enable alternating row backgrounds for better visual distinction.

  • bordered

Only available on macOS, adds borders to the Table.

  • bordered(alternatesRowBackgrounds: Bool)

Only available on macOS, it allows setting whether to enable alternating row backgrounds for better visual distinction.

Perhaps in future beta versions, SwiftUI will expand more styles to the iPadOS platform.

Row Selection

Enabling row selection in a Table is similar to how it's done in a List:

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

It's important to note that the variable type bound to the Table's selection must match the id type of the data (which needs to conform to the Identifier protocol). For example, in this example, the id type of LocaleInfo is String.

@State var selection: String?  // Single selection
@State var selections: Set<String> = [] // Multiple selection, requires LocaleInfo to conform to Hashable protocol

The following image shows what it looks like with multiple selection enabled:

Sorting

One of the key features of Table is the ability to efficiently sort by multiple attributes.

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 itself does not modify the data source. When a sorting variable is bound to Table, clicking on the header of a sortable column will automatically update the content of the sorting variable. Developers still need to monitor changes to the sorting variable for sorting purposes.

Table requires the sorting variable to be an array that conforms to SortComparator. In this example, we directly use the KeyPathComparator type provided by Swift.

If you don’t want a column to support sorting, simply do not use the TableColumn constructor with the value parameter. For example:

TableColumn("货币代码", value: \.currencyCode) // 启用以该属性为依据的排序
TableColumn("货币代码"){ Text($0.currencyCode) } // 不启用以该属性为依据的排序

// 切勿在不绑定排序变量时,使用如下的写法。应用程序将无法编译( 并且几乎不会获得错误提示 )
TableColumn("价格",value: \.currencyCode) {
Text("\($0.price)")
.foregroundColor($0.price > 4 ? .red : .green)
}

Currently in beta version 14A5228q, enabling sorting on a column with a Bool property type will cause the application to fail to compile.

Although only one column header shows the sorting direction after clicking, Table will actually add or rearrange the sorting order of the sorting variable according to the order of the user's clicks. The following code clearly illustrates this:

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

If you are concerned about the performance of sorting by multiple attributes, especially when dealing with large amounts of data, you can only use the last created sorting condition:

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

When converting SortComparator to SortDescription (or NSSortDescription) for use with Core Data, please do not use compare algorithms that are not supported by Core Data.

Drag and Drop

Table supports Drag & Drop at the row level. When Drag support is enabled, the simplified version of Table cannot be used:

swiftCopy code
Table { }

Interaction

In addition to row selection and row drag and drop, Table also supports setting a context menu for rows (macOS 13+, iPadOS 16+):

ForEach(localeInfos) { localeInfo in
TableRow(localeInfo)
.contextMenu{
Button("编辑"){}
Button("删除"){}
Button("共享"){}
}
}

Creating interactive cells can greatly improve the user experience of the table.

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

Pioneer or Martyr?

If you’re writing code using Table in Xcode, you’re likely to run into issues where the autocomplete feature doesn’t work, and sometimes the application may fail to compile without any clear error message (the error occurred inside Table).

The main reason for these issues is that Apple has taken a groundbreaking approach to Table by using a result builder to write its own DSL (Domain-Specific Language), instead of adopting the usual way of writing SwiftUI controls with either a native SwiftUI container or wrapping a UIKit control.

Perhaps due to the inefficiency of Table’s DSL (too many generics, too many constructors, and two builders in one Table), the current version of Xcode struggles to handle Table code.

Additionally, due to the incomplete definition of the Table DSL (lack of container similar to Group), it can only support a maximum of ten columns of data at the moment (for more details, please refer to ViewBuilder Study (Part II) — Learning by Imitation).

Apple may have learned from the Table DSL’s lesson, and the SwiftUI Charts (also based on a result builder) introduced at WWDC 2022 outperforms Table in Xcode.

We hope that Apple can apply the experience gained from Charts to Table, and prevent the pioneer from becoming a martyr.

Creating Tables on Other Platforms

Although Table can run on an iPhone with iOS 16, it only displays the first column of data and, therefore, lacks practical meaning.

If you want to implement table functionality on platforms that Table does not support or does not fully support (such as iPhone), choose an appropriate alternative based on your needs:

  • For large amounts of data that need to be lazy-loaded, use List and LazyVGrid.
  • For row-based interactive operations (drag and drop, context menu, and selection), use List (GridRow in Grid is not a true row).
  • For rendering views that can be saved as images, use LazyVGrid and Grid.
  • For fixed header rows, use List, LazyVGrid, or Grid (e.g., using matchedGeometryEffect).

Conclusion

If you want to create interactive tables in SwiftUI with less code and clearer expressions, consider trying Table. At the same time, we hope that Apple can improve Table’s development efficiency in Xcode in future versions and add more native functionality to it.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

fatbobman ( 东坡肘子)

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com