Multi-column tables have long been a feature of macOS. SwiftUI added support for them in macOS 12.0. They came to iOS a year later in iOS 16. There are some caveats and sharp edges to be aware of if you’re thinking of using them.
Multi-column Tables
A Table
is a container view that shows rows of data arranged in one or more columns. It works best on macOS or iPadOS where it can make use of the greater screen space:
You interact with the table by selecting one or more rows and then performing actions from the toolbar or a context menu. The table scrolls vertically if needed. On macOS, but not iPadOS, the table will also scroll horizontally if needed.
In a compact horizontal size class environment the table hides the headers and reduces to a single column list. For example, on an iPhone:
Creating A Table
Showing a collection of data in a table requires that the each data item is Identifiable
. In my example, I’m using a Country
struct:
struct Country: Identifiable {
var id: Int
var name: String
var capital: String
var continent: String
var currency: String
var area: Double
var population: Int
var visited: Bool
}
My table view has an observable store object that publishes the country data that I then provide to the table:
struct FactTableView: View {
@EnvironmentObject var store: WorldStore
Table(store.countries) {
}
}
When you add columns to the table you pass a label, an optional key path, and a content view for the row. For String
properties it’s enough to give the key path to the property. The String
value is automatically wrapped in a Text
view:
Table(store.countries) {
TableColumn("Name", value: \.name)
TableColumn("Capital", value: \.capital)
...
}
For other properties, or when you want to control the formatting you can pass a content closure. For example, two columns that use formatted Int
and Double
values:
TableColumn("Population") { country in
Text(country.formattedPopulation)
}
TableColumn("Area") { country in
Text(country.formattedArea)
}
Unfortunately, here comes the first caveat. My Country
struct has a boolean visited property but this will not work:
TableColumn("Visited", value: \.visited) { country in
Text(country.visited ? "Yes" : "No")
}
The TableColumn
initializer that allows a boolean key path only works with objects that conform to NSObject
. The same is true for other non-string types including optional strings. For now, I can omit the key path but this becomes a problem later if we want to sort the table:
TableColumn("Visited") { country in
Text(country.visited ? "Yes" : "No")
}
Note: There is a workaround for this if we define our own sort comparators. I’ll looks at that in a future post.
One other problem, is that the compiler has to do a lot of work to infer types. If the table gets too complicated it gives up and suggest you submit a bug report:
Some older table initializers were deprecated in iOS 16.2 to improve compiler performance. This includes the initializer for building tables with static rows which now requires you to provide the type of the row values:
Table(of: Country.self) {
TableColumn("Name", value: \.name)
TableColumn("Capital", value: \.capital)
TableColumn("Continent", value: \.continent)
} rows: {
ForEach(Country.data) { country in
TableRow(country)
}
}
Row Selection
Table row selection works like SwiftUI list selection. If you only want single selections pass the table a binding to an optional identifier of the item. Binding to a set of identifiers allows multiple selections:
// @State private var selected: Country.ID?
@State private var selected = Set<Country.ID>()
var body: some View {
Table(store.countries, selection: $selected) { ... }
}
Sort Order
The user can sort a table by clicking on the different column headers. To enable sorting provide a binding to an array of sort comparators:
@State private var sortOrder = [KeyPathComparator(\Country.name)]
var body: some View {
Table(store.countries, selection: $selected,
sortOrder: $sortOrder) { ... }
}
I’ve written about SortComparator before. It’s a Swift friendly version of NSComparator
added back in iOS 15. The KeyPathComparator
type also added in iOS 15 conforms to SortComparator
so we can set our initial sort order with a key path to the name property of a country.
You perform the sorting in an onChange handler anytime the sortOrder changes:
.onChange(of: sortOrder) {
store.sortOrder = $0
}
In my case, I pass the new sort order into my store object which sorts the data source and publishes the updated country data.
Unfortunately, here’s another caveat. Table sorting only works for columns with key paths. So we cannot sort on the integer and boolean columns. Even worse, I can only seem to get it to work on the first column:
Table Column Widths
You can specify a fixed width for a table column:
TableColumn("Name", value: \.name)
.width(300)
I’ve only been able to get it to work on macOS but you can also set minimum and maximum values for a resizeable column:
TableColumn("Name", value: \.name)
.width(min: 150, max: 300)
Adjusting For Compact Size Classes
When viewed in a compact horizontal size class the table hides the headers and collapses to show only the first column. You can choose to detect this condition and provide more detail in the first column to give a better list-like appearance:
We first need to get the horizontal size class from the environment:
struct FactTableView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompact: Bool {
horizontalSizeClass == .compact
}
Then in the name column I’ll add the capital as sub-heading when the horizontal size class is compact:
TableColumn("Name", value: \.name) { country in
VStack(alignment: .leading) {
Text(country.name)
if isCompact {
Text(country.capital)
.foregroundStyle(.secondary)
}
}
}
Working With Core Data
As I mentioned above the SwiftUI table is somewhat limited if you’re working with Swift value types for your data. If your data conforms to NSObject
it has a few more options. This means it works well with Core Data.
Let’s switch my model to Core Data and use a managed object for my country:
class Country: NSManagedObject, Identifiable {
@NSManaged public var id: Int64
@NSManaged public var name: String
@NSManaged public var capital: String?
...
}
My table view is now populated by a Core Data fetch request:
struct FactTableView: View {
@State private var selectedCountry = Set<Country.ID>()
@FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
private var countries: FetchedResults<Country>
var body: some View {
Table(countries, selection: $selectedCountry,
sortOrder: $countries.sortDescriptors) {
TableColumn("Name", value: \.name)
TableColumn("Capital", value: \.capital) {
Text($0.capital ?? "")
}
TableColumn("Continent", value: \.continent)
TableColumn("Currency", value: \.currency) {
Text($0.currency ?? "")
}
TableColumn("Population", value: \.population) {
Text($0.formattedPopulation)
}
TableColumn("Area", value: \.area) {
Text($0.formattedArea)
}
TableColumn("Visited", value: \.visited) {
Text($0.visited ? "Yes" : "No")
}
}
}
}
Notes:
-
Selection works as before with a binding to a set of country ID’s.
-
I’ve configured the fetch request with a default sort descriptor to sort the countries by name in ascending order. See Configuring SwiftUI Fetch Requests for more details.
-
We no longer need to observe and handle changes to the sort order. We can supply a binding to the sort descriptors of the fetch request. When the user changes the sort order SwiftUI takes care of triggering the request with the new sort descriptor.
-
We can now create table columns with key paths to optional strings, integers, doubles and booleans. This means we can now also sort on these columns.
Compared to using a struct for our model data the table is both easier to work with and more of it works as expected (here I’m sorting by population):