SwiftUI Tables Quick Guide

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:

Multi-column table

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:

Table on an iPhone becomes single column list

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 custom sort comparators.

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:

Failed to produce diagnostic for expression

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

Table with two rows 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 sorted on name, First row is Zimbabwe

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

Table on an iPhone with Country and capital

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

Table of countries sorted by population