NEW: My new book Pro SwiftUI is out now – level up your SwiftUI skills today! >>

How to create multi-column lists using Table

Paul Hudson    @twostraws   

Updated for Xcode 14.2

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
}

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.

Hacking with Swift is sponsored by Essential Developer

SPONSORED From March 20th to 26th, you can join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer!

Click to save your free spot now

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.8/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.