WWDC22 SALE: Save 50% on all my Swift books and bundles! >>

How to create multi-column lists using Table

Paul Hudson    @twostraws   

Updated for Xcode 14.0 beta 1

macOS has a dedicated Table view type for creating lists with multiple columns, including selection and sorting. They work quite differently from lists, because we pass the Table an array of data to show then specify values to display using key paths, each time also passing a title to show in the header area.

As an example, we might have a simple User struct like this one:

struct User: Identifiable {
    let id: Int
    var name: String
    var score: Int
}

I’ve used both a String and Int for types there, because both are used slightly differently inside Table.

With that User struct in place, we could then go ahead and create a few instances of that struct, then display it using a simple Table like this:

struct ContentView: View {
    @State private var users = [
        User(id: 1, name: "Taylor Swift", score: 95),
        User(id: 2, name: "Justin Bieber", score: 80),
        User(id: 3, name: "Adele Adkins", score: 85)
    ]

    var body: some View {
        Table(users) {
            TableColumn("Name", value: \.name)
            TableColumn("Score") { user in
                Text(String(user.score))
            }
        }
    }
}

Notice how:

  1. I’ve declared the array as being mutable using @State so that we can add sorting shortly. If you don’t need sorting, a simple constant is fine.
  2. Both table columns have a string title: “Name” and “Score”.
  3. The first table column reads its value using the simple key path \.name.
  4. The second table column uses no key path, but instead creates its own custom contents.

The reason I’ve taken two different approaches is because TableColumn knows how to use a key path to display values that are simple strings, but for anything else – a score integer in our case – we need to add custom view creation code by hand. So, when you just want to display a simple string you should use a key path, but for displaying all other types you should use a custom view.

Selection in tables works slightly differently from lists: rather than storing the specific object that was selected, Table instead wants to bind to the identifier of the object. As our User struct conforms to Identifiable, this will be User.ID – the associated type that points to our id property. So, we’d add a new property to store an optional User.ID value, then bind it to the Table like this:

struct ContentView: View {
    @State private var users = [
        User(id: 1, name: "Taylor Swift", score: 95),
        User(id: 2, name: "Justin Bieber", score: 80),
        User(id: 3, name: "Adele Adkins", score: 85)
    ]

    @State private var selection: User.ID?

    var body: some View {
        Table(users, selection: $selection) {
            TableColumn("Name", value: \.name)
            TableColumn("Score") { user in
                Text(String(user.score))
            }
        }
    }
}

Tip: If you want multiple rows to be selectable, use selection = Set<User.ID>() rather than selection: User.ID?.

Adding sorting to the mix takes four steps:

  1. Make an array of KeyPathComparator objects with your default sorting inside.
  2. Add that as the sortOrder parameter to your Table initializer.
  3. Add a specific key path for the “Score” column, so SwiftUI understands what sorting it means.
  4. Watch for changes in the sort order, and resort your array as needed. We already marked our array using @State, so we can sort it in place.

Here’s how our example code looks with those four changes in place:

struct ContentView: View {
    @State private var users = [
        User(id: 1, name: "Taylor Swift", score: 95),
        User(id: 2, name: "Justin Bieber", score: 80),
        User(id: 3, name: "Adele Adkins", score: 85)
    ]

    @State private var sortOrder = [KeyPathComparator(\User.name)]
    @State private var selection: User.ID?

    var body: some View {
        Table(users, selection: $selection, sortOrder: $sortOrder) {
            TableColumn("Name", value: \.name)
            TableColumn("Score", value: \.score) { user in
                Text(String(user.score))
            }
        }
        .onChange(of: sortOrder) { newOrder in
            users.sort(using: newOrder)
        }
    }
}

There are two extra things you’ll want to know when using Table. First, you can set a specific width for one or more columns using a width() modifier. This can be a fixed value, like this:

TableColumn("Score", value: \.score) { user in
    Text(String(user.score))
}
.width(100)

Or you can provide a range of widths, like frame():

TableColumn("Score", value: \.score) { user in
    Text(String(user.score))
}
.width(min: 50, max: 100)

And second, rather than sending a fixed into Table, you can also pass a rows closure that specifies the exact data you want to show. This is helpful when you want to use static list rows, or mix static and dynamic at the same time. Each row needs to be sent in as a TableRow instance, which will take as its only parameter a value to show – one of our User instances in our case.

As an example, we could use a ForEach to create all the regular dynamic rows, but also add a “First” and “Last” user at the start and end of our table:

struct ContentView: View {
    @State private var users = [
        User(id: 1, name: "Taylor Swift", score: 90),
        User(id: 2, name: "Justin Bieber", score: 80),
        User(id: 3, name: "Adele Adkins", score: 85)
    ]

    @State private var sortOrder = [KeyPathComparator(\User.name)]
    @State private var selection: User.ID?

    var body: some View {
        Table(selection: $selection, sortOrder: $sortOrder) {
            TableColumn("Name", value: \.name)
            TableColumn("Score", value: \.score) { user in
                Text(String(user.score))
            }
            .width(min: 50, max: 100)
        } rows: {
            TableRow(User(id: 0, name: "First", score: 0))
            ForEach(users, content: TableRow.init)
            TableRow(User(id: 4, name: "Last", score: 100))
        }
        .onChange(of: sortOrder) { newOrder in
            users.sort(using: newOrder)
        }
    }
}

Tip: As the two extra rows are fixed data, they won’t be affected by any sorting in the users array.

Save 50% in my Black Friday sale.

SAVE 50% To celebrate WWDC22, all our books and bundles are half price, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Sponsor Hacking with Swift and reach the world's largest Swift community!

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 3.0/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.