Custom Sort Comparators

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:

Xcode build failure. The compiler is unable to type-check this expression in reasonable time

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:

  1. A sort order property:

    // .forward or .reverse
    var order: SortOrder { get set }
    
  2. 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:

Table sorted on capital with Antartica listed first with empty capital