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:
@State
so that we can add sorting shortly. If you don’t need sorting, a simple constant is fine.\.name
.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:
KeyPathComparator
objects with your default sorting inside.sortOrder
parameter to your Table
initializer.@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.
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!
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.