SwiftUI Table is a container that arranges rows of data in one or more columns. All Columns are expandable. A table header is displayed and you can tap on a header cell to sort by the corresponding column. The table allows the selection of one or more members. In contrast a List view is only one column wide and more typical for iOS.
In this blog post, we will explore how to create a Table using SwiftUI and the different options available to customize its behavior. We will also take a look at some examples to understand how to work with tables effectively.
What is the difference between List, ScrollView, and Table in SwiftUI?
In SwiftUI, List is a container view displaying data rows arranged in a single column. It is used to display a collection of data that can be scrolled through vertically. It is similar to a UITableView in UIKit. Learn more here.
ScrollView is a container view that allows scrolling through its content. It can be used to display a single view or multiple views. It can scroll horizontally, or vertically, but does not provide zooming functionality. Have a look at some examples here.
Table is a new SwiftUI view that is specific to macOS, but recently also added support for iPad starting with iOS 16. Data is displayed in multiple columns. Table supports vertical scrolling automatically.
An alternative for multiple columns is Grid view, but it does not support dynamic loading. This makes it limited to small data sets.
How to create a table with one or more columns in SwiftUI?
The following example displays an arrow of Customer data type.
struct Customer: Identifiable {
let id = UUID()
let name: String
let email: String
let creationDate: Date
}
You can create a basic table with the Table initialiser and specify an array of data that you want to show and what columns to use.
struct ContentView: View {
@State var customers = [Customer(name: "John Smith",
email: "john.smith@example.com",
creationDate: Date() + 100),
Customer(name: "Jane Doe",
email: "jane.doe@example.com",
creationDate: Date() - 3000),
Customer(name: "Bob Johnson",
email: "bob.johnson@example.com",
creationDate: Date())]
var body: some View {
Table(customers) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at") { customer in
Text(customer.creationDate, style: .date)
}
}
}
}
If your property type is a String, like for my example name and email, you can use the simple TableColumn like so:
TableColumn("name", value: \.name)
where “name” will be displayed in the table header of that column and the value is the key path of that table column. In this case, it will use the name property of the Customer type.
Working With Non-String Types
If your property type is not a String, like for my example the creation date, you can use a more flexible TableColumn initialiser and specify what view is shown in that column:
TableColumn("joined at") { customer in
Text(customer.creationDate, style: .date)
}
This works well for Date, Int, and Double. I found some issues for enum and boolean values when it comes to sorting, which I will show you further down below. If you only want to show data in a table without user interactions, things are fairly straightforward.
Building Tables with Static Rows
In addition to creating tables from collections of data, you can also create tables with static rows. This can be useful when you need to display a table with a fixed set of data, or when you want to display a table with data that is not easily represented by a collection.
To create a table with static rows, you provide both the columns and the rows. The Table view provides an initializer that takes both of these parameters: init(of:columns:rows:). You need to specify the type of data, here Customer type. In the rows closure, you can specify what TableRows you want to display. TableRow takes in arguments of your specified type. This also means you cannot work with mixed types.
Table(of: Customer.self) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at") { customer in
Text(customer.creationDate, style: .date)
}
} rows: {
TableRow(Customer(name: "static customer",
email: "fake email",
creationDate: Date()))
}
In addition, you can use dynamic data from arrays and static data together. This is a simple demonstration:
struct ContentView: View {
@State var customers = [Customer(name: "John Smith",
email: "john.smith@example.com",
creationDate: Date() + 100),
...]
var body: some View {
Table(of: Customer.self) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at") { customer in
Text(customer.creationDate, style: .date)
}
} rows: {
ForEach(customers) { customer in
TableRow(customer)
}
TableRow(Customer(name: "Dummy",
email: "abc@xwz.com",
creationDate: Date()))
}
}
}
Making Tables Selectable
Tables can be made more interactive by allowing users to select one or more rows. To do this, you can provide a binding to a selection variable when creating the Table view.
How to Allow One Row Selectable
If you want to create a single-selection table, you can bind to a single instance of the table data’s ID type.
struct ContentView: View {
@State private var customers = [Customer(name: "John Smith",
email: "john.smith@example.com",
creationDate: Date() - 50000),
...]
@State private var selection: Customer.ID? = nil
var body: some View {
Table(customers, selection: $selection) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at") { customer in
Text(customer.creationDate, style: .date)
}
}
}
}
How to Allow Selecting Multiple Rows
If you want to create a table that supports multiple selections, you can bind to a Set of IDs. Here’s an example of how to add multi-select to the previous example:
struct ContentView: View {
@State private var customers = [Customer(name: "John Smith",
email: "john.smith@example.com",
creationDate: Date() - 50000),
...]
@State private var selection: Set<Customer.ID> = []
var body: some View {
Table(customers, selection: $selection) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
}
}
}
How to add a context menu to tables
You can add a context menu to each of your table rows. This is not the same as the contextMenu that works for all SwiftUI views because it needs to work with TableRow. Here is a simple example that demonstrates where to add a context menu for TableRow:
struct ContentView: View {
@State private var customers = [...]
var body: some View {
Table(of: Customer.self) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at") { customer in
Text(customer.creationDate, style: .date)
}
} rows: {
ForEach(customers) { customer in
TableRow(customer)
.contextMenu {
Button("Edit") {
// TODO open editor in inspector
}
Button("See Details") {
// TODO open detai view
}
Divider()
Button("Delete", role: .destructive) {
delete(customer)
}
}
}
}
}
func delete(_ customer: Customer) {
if let index = customers.firstIndex(of: customer) {
customers.remove(at: index)
}
}
}
You can add different context menus for different row data. One thing, that I could not accomplish is to have a red delete button. Usually the button role of ´destructive´can give you this appearance. Also, icons for the buttons don’t show off in the context menu.
Sort Table Columns
Sorting is a common feature in tables, and SwiftUI provides a simple way to add sorting capability to your tables. To make the columns of a table sortable, provide a binding to an array of SortComparator instances. The table reflects the sorted state through its column headers, allowing sorting for any columns with key paths. If you don’t specify the value property of the column, the table does not have a key path to sort by and sorting is disabled for that column.
SwiftUI table updates the header when you change the sorting. You can see a small indicator in the column it uses for sorting.
When the table sort descriptors update, re-sort the data collection that underlies the table. The table itself doesn’t perform a sort operation. You can watch for changes in the sort descriptors by using an onChange(of:perform:) modifier, and then sort the data in the modifier´s closure.
Let’s modify the previous example to add sorting capability:
struct ContentView: View {
@State private var customers = [...]
@State private var sortOrder = [KeyPathComparator(\Customer.email,
order: .reverse)]
var body: some View {
Table(customers, sortOrder: $sortOrder) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at", value: \.creationDate) { customer in
Text(customer.creationDate, style: .date)
}
}
.onChange(of: sortOrder) { newOrder in
customers.sort(using: newOrder)
}
}
}
In this example, we’ve added a sortOrder state variable that is bound to the table’s sortOrder parameter. We’ve initialized sortOrder with a single KeyPathComparator instance that sorts by the givenName property of our Customer instances.
We’ve also added an onChange(of:perform:) modifier to the table, which watches for changes to sortOrder and sorts the customer array using the new sort descriptors.
Now, when the user taps on a column header, the table will re-sort its rows based on the selected column. The header for the selected column will also indicate the current sort order with an arrow icon.
Working with Boolean Values to Support Sorting
If you try to use a boolean property with TableColumns, you will run into this error: “Referencing initializer ‘init(_:value:content:)’ on ‘TableColumn’ requires that ‘Customer’ inherit from ‘NSObject'”.
A simple workaround is to use a computed property instead:
struct Customer: Identifiable {
...
let isSubscribed: Bool
var fomattedisSubscribed: String {
isSubscribed ? "subscribed" : "not subscribed"
}
}
And then call this wrapper property from the Table:
TableColumn("is Subscribed", value: \.fomattedisSubscribed) { customer in
Text("\(customer.isSubscribed ? "yes" : "no")")
}
Filtering Table Data
If you want to filter your table data, you can use a search text field and use the text property to filter your data. For examples of how to implement this, you can check this blog post about “Searchable View Modifier”. Additionally, you can use menus and pickers to filter data by specific categories. To get an idea of how to use SwiftUI Picker, check out this post about “SwiftUI Picker Made Easy: Tutorial With Example”.
SwiftUI Tables for iOS and iPadOS
One of the benefits of SwiftUI is that you can define a single table for use on macOS, iOS, and iPadOS. However, in a compact horizontal size class environment, such as on an iPhone or on an iPad in certain modes like Slide Over, the table has limited space to display its columns. To conserve space, the table automatically hides headers and will show only the first column.
If I use the table example from above and place it both in the sidebar and detail view of a NavigationSplitView, you can see the table shows different display modes:
NavigationSplitView(sidebar: {
ContentView()
}, detail: {
ContentView()
})
Using a different view for iOS and macOS
One strategy would be to use different views for horizontal-size classes. In the following example, a List view is shown for iOS and on the iPad for compact mode.
struct ContentView: View {
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompact: Bool { horizontalSizeClass == .compact }
#else
private let isCompact = false
#endif
@State private var customers = [...]
var body: some View {
if isCompact {
List(customers) { customer in
VStack(alignment: .leading) {
Text(customer.name)
Text(customer.email)
.foregroundStyle(.secondary)
}
}
} else {
Table(customers, sortOrder: $sortOrder) {
TableColumn("name", value: \.name)
TableColumn("email", value: \.email)
TableColumn("joined at", value: \.creationDate) { customer in
Text(customer.formattedDate)
}
}
}
}
}
Adjusting the First Column for Compact Horizontal Size Class
The Apple documentation suggests to adjusts the first column for compact mode:
struct ContentView: View {
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompact: Bool { horizontalSizeClass == .compact }
#else
private let isCompact = false
#endif
@State private var customers = [...]
var body: some View {
Table(customers) {
TableColumn("name", value: \.name) { customer in
VStack(alignment: .leading) {
Text(customer.name)
if isCompact {
Text(customer.email)
.foregroundStyle(.secondary)
}
}
}
TableColumn("email", value: \.email)
TableColumn("joined at", value: \.creationDate) { customer in
Text(customer.formattedDate)
}
}
}
}
Customizing SwiftUI Table Appearance
One of the simplest ways to change the style of a table is to use the tableStyle(_:) modifier. This modifier takes a single argument of type TableStyle, which specifies the style to use for the table. For example, you can use the InsetTableStyle to create a table with inset content:
Table(customers) {
// table columns...
}
.tableStyle(InsetTableStyle())
iOS only has InsetTableStyle available. On macOS, you can use the BorderedTableStyle to create a table with borders around each cell. Per default, the table uses an alternating background row color. You can disable this behavior by:
.tableStyle(.bordered(alternatesRowBackgrounds: false))
How to Change the Scrolling Behavior
Per default, the table scrolls vertically. If the available space is not enough to fit all columns, the table expands horizontally and you can scroll horizontally.
If you want to disable scrolling entirely, you can use the new SwiftUI modifier:
Table() {
...
}
.scrollDisabled(true)
Setting a Fixed Width for the Table Column
Since TableColumn is not a view, you cannot simply use a frame modifier. Luckily, Apple added a new modifiers specific for columns:
TableColumn("name", value: \.name)
.width(50)
If you want to restrict the column width to a specific range you can use the following width modifier:
TableColumn("email", value: \.email)
.width(min: 30, ideal: 80, max: 100)
Conclusion
In summary, SwiftUI tables are a powerful way to display collections of data in columns and rows. They can be made selectable and sortable, and can even be built with static rows. Tables can be styled using the tableStyle(_:) modifier and can be customized for different platforms and size classes. By following the examples, developers can quickly and easily create tables that are both functional and visually appealing. Overall, SwiftUI tables are a valuable tool for any developer working with collections of data in their app.
Further Reading
How to generate TableColumn by loop? for example: ForEach
Thank you, Karin! I haven’t had any luck searching for how to edit rows in a Table. Your explanation is very helpful!
Yes, I did not see anything either. In the end I just explored how I would edit Table like any other macOS app.
Do you have another video that explains how to populate the edit button in the context menu?
Sorry, I don’t have another video. What exactly do you want to happen when the user presses ´edit´?
I don´t think you can edit cells in table currently https://developer.apple.com/forums/thread/693236.
Alternatively, you could:
– Open an Edit View: Present a new popup view or fullscreen cover where the user can modify the details of the selected item. This could be a form with pre-populated fields based on the current data.
– Show a Detailed Inspector: If your app has a sidebar or an inspector area, you could show detailed information about the selected item there, along with options to edit its properties.
Karin,
Great video… purchased the Layout Cookbook and look forward to browsing the Tables in more detail!
Is there a link to the WWDC Project you show in the above video?
Here is the link to the Food Truck Demo app:
https://developer.apple.com/documentation/swiftui/food_truck_building_a_swiftui_multiplatform_app
Hello Karin,
I love your video and like the way you break down the different capabilities.
I’ve successfully added the sorting and context menu but when I add filtering, I get the error: “Cannot convert value of type ‘[Customer]’ to expected argument type ‘Customer.Type'”
My Table statement looks like this;
Table(of: filteredCustomers, selection: $selection, sortOrder: $sortOrder) {
I’m using Xcode 15.2 and Swift 5.9.2
Any idea what’s wrong?
Thank you