Are you using SwiftUI table views with a row model that’s a value type? Frustrated by the lack of support for sorting columns for common types like optional strings and booleans? You need a custom sort comparator.
Sorting Tables
When I was looking at SwiftUI Tables I mentioned a caveat when sorting the table columns. Sorting requires you to give the key path to the property when creating the column. Unfortunately, when working with value types (when your model is a struct) the table column initializers only support key paths for a limited number of types like String
and Int
:
@State private var selected = Set<Country.ID>()
@State private var sortOrder = [KeyPathComparator(\Country.name)]
var body: some View {
Table(store.countries, selection: $selected,
sortOrder: $sortOrder) {
TableColumn("Name", value: \.name)
...
}
}
My row values are Country
structs which contain optional strings and boolean properties. I want to be able to create sortable table columns for each of these properties:
// Optional String
TableColumn("Capital", value: \.capital) { country in
Text(country.capital ?? "")
}
// Boolean
TableColumn("Visited", value: \.visited) { country in
Text(country.visited ? "Yes" : "No")
}
Unfortunately, the table column initializers that accept key paths to properties like optional strings and booleans only work for row values that conform to NSObject
. Even worse, the above code causes the compiler to slow down and eventually give up without telling you what’s wrong:
What can we do when our model is a struct?
Creating Columns for Comparable Values
There is another table column initializer that creates a sortable column using an explicit sort comparator:
// Boolean
TableColumn("Visited", value: \.visited, comparator: ???) {
// Content
}
We can create sort comparators for the unsupported types in our model. I’ve written about SortComparator before. There are two requirements for a type to conform:
-
A sort order property:
// .forward or .reverse var order: SortOrder { get set }
-
A comparison method that returns the relative order of two elements based on the sort order:
func compare(_ lhs: Self.Compared, _ rhs: Self.Compared) -> ComparisonResult
Sorting Booleans
Let’s start by creating a sort comparator for boolean types. I’m using a custom type that implements the conformance. Here’s the starting point:
struct BoolComparator: SortComparator {
var order: SortOrder = .forward
func compare(_ lhs: Bool, _ rhs: Bool) -> ComparisonResult {}
}
We need to add the details of the compare method which requires us to decide how we want to order booleans? I’ve decided that false comes before true when sorting in ascending order:
func compare(_ lhs: Bool, _ rhs: Bool) -> ComparisonResult {
switch (lhs, rhs) {
case (true, false):
return order == .forward ? .orderedDescending : .orderedAscending
case (false, true):
return order == .forward ? .orderedAscending : .orderedDescending
default: return .orderedSame
}
}
Using this to create a table column for the boolean visited property:
TableColumn("Visited", value: \.visited,
comparator: BoolComparator()) { country in
Text(country.visited ? "Yes" : "No")
}
Sorting Optional Strings
We can do something similar for optional strings. When sorting in ascending order I’m placing nil values first and using the standard localized compare for comparing two strings:
struct OptionalStringComparator: SortComparator {
var order: SortOrder = .forward
func compare(_ lhs: String?, _ rhs: String?) -> ComparisonResult {
let result: ComparisonResult
switch (lhs, rhs) {
case (nil, nil): result = .orderedSame
case (.some, nil): result = .orderedDescending
case (nil, .some): result = .orderedAscending
case let (lhs?, rhs?): result = lhs.localizedCompare(rhs)
}
return order == .forward ? result : result.reversed
}
}
I’m using a small extension to reverse the comparison result depending on the sort order:
extension ComparisonResult {
var reversed: ComparisonResult {
switch self {
case .orderedAscending: return .orderedDescending
case .orderedSame: return .orderedSame
case .orderedDescending: return .orderedAscending
}
}
}
Using this to create a table column for the optional capital property of my country model:
// Optional String
TableColumn("Capital", value: \.capital,
comparator: OptionalStringComparator()) { country in
Text(country.capital ?? "")
}
It Almost Works
Putting it all together here’s my final table using the optional string and boolean comparators:
Table(countries, selection: $selection,
sortOrder: $sortOrder) {
TableColumn("Name", value: \.name)
TableColumn("Capital", value: \.capital,
comparator: OptionalStringComparator()) { country in
Text(country.capital ?? "")
}
TableColumn("Continent", value: \.continent)
TableColumn("Currency", value: \.currency,
comparator: OptionalStringComparator()) { country in
Text(country.currency ?? "")
}
TableColumn("Population", value: \.population) { country in
Text(country.formattedPopulation)
}
TableColumn("Area", value: \.area) { country in
Text(country.formattedArea)
}
TableColumn("Visited", value: \.visited,
comparator: BoolComparator()) { country in
Text(country.visited ? "Y" : "N")
}
}
Unfortunately this still doesn’t always work. An iPad running iOS 16.4 seems to only support sorting of the first column. It does work on macOS, here sorted by capital in ascending order so those countries where the capital is nil are shown first: