BLACK FRIDAY SALE: Save big on all my Swift books and bundles! >>

How to create multi-column lists using Table

Paul Hudson    @twostraws   

Updated for Xcode 14.1

New in iOS 16

SwiftUI 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.

Important: On iPhone tables are collapsed down to show just the first column of data, but on iPad and Mac they will show all their data.

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
}

Download this as an Xcode project

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

Download this as an Xcode project

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

Download this as an Xcode project

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

Download this as an Xcode project

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

Download this as an Xcode project

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

Hacking with Swift is sponsored by RevenueCat

SPONSORED In-app subscriptions are a pain to implement, hard to test, and full of edge cases. RevenueCat makes it straightforward and reliable so you can get back to building your app. Oh, and it's free if your app makes less than $10k/mo.

Learn more

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

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI 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 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 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.