The Quest for Butter-Smooth Table Sorting on macOS

Published by malhal on

If you’ve spent any time building macOS apps with SwiftUI, you know that Table sorting is a minefield. It’s one thing to get the data to change order; it’s another entirely to get it to animate like a native AppKit app.

Here is the evolution of sorting strategies, from the “standard” mistakes to the surgical precision of Synchronous State Projection.


1. Derived State: The “Simple” Performance Trap

The most common advice is to use a computed property to derive your sorted list directly in the body.

Why it’s inefficient: In SwiftUI, the body is not a one-time setup; it is a high-frequency execution loop. Every time an environment value changes, a button is clicked, or a parent view re-computes, your body runs.

var sortedItems: [Item] {
    items.sorted(using: sortOrder)
}

var body: some View {
    Table(sortedItems, sortOrder: $sortOrder) { ... }
}
  • O(N log N) overhead: Even if the data hasn’t changed, you are paying the tax of a full sort every single time SwiftUI “pings” your view.
  • Main Thread Blocking: For lists of hundreds or thousands of items, this creates “micro-stutters.” You should avoid heavy computation inside body to keep the UI thread free for rendering.

2. Reactive Sorting: The Animation Breaker

To avoid sorting in every render, many move the logic to .onChange.

Table(items, sortOrder: $sortOrder) { ... }
    .onChange(of: sortOrder) { newOrder in
        withAnimation {
            items.sort(using: newOrder)
        }
    }

Why it breaks animations: This creates a State Mismatch. When you click a table header:

  1. sortOrder updates immediately.
  2. SwiftUI triggers a render. The Table sees the new sort arrow but the old, unsorted data. It tries to animate, but the data doesn’t match the expectation.
  3. Then .onChange fires, and the data actually moves. The result is a “snap” or a “flicker” where the table jumps to the end state instead of sliding rows.

3. Computed Bindings: The Transaction Failure

You might try a “proactive” approach using a custom Binding setter to catch the sort intent before the render happens.

var sortOrderBinding: Binding<[KeyPathComparator<Item>]> {
    Binding {
        sortOrder
    } set: { newOrder, transaction in
        withTransaction(transaction) {
            sortOrder = newOrder
            items.sort(using: newOrder)
        }
    }
}

The Verdict: Apple’s sample code almost never uses withTransaction for a reason. While this feels “surgical,” the Tablecomponent is deeply tied to the underlying NSTableView. If the data isn’t already in its final form the moment the view updates, the animation often gets dropped or misinterpreted.


4. The Gold Standard: Synchronous State Projection

The optimal way to fix “interference” and animation bugs is to use a dedicated State Struct—let’s call it TableConfig—that projects the sorted state synchronously during the mutation.

By using didSet, we ensure that the data can never be wrong. By the time SwiftUI reads the view, the data is already sorted.

struct TableConfig {
    // 1. The Raw Input (Source of Truth)
    var items: [Item] = [] {
        didSet { update() }
    }
    
    // 2. The Configuration
    var sortOrder: [KeyPathComparator<Item>] = [.init(\.timestamp)] {
        didSet { update() }
    }
    
    // 3. The Projected Result (Ready for the Table to read)
    private(set) var sortedItems: [Item] = []
    
    private mutating func update() {
        // Atomic update: Both inputs result in a single, stable output
        sortedItems = items.sorted(using: sortOrder)
    }
}

Why This is Optimal:

  • Atomic Consistency: There is no “gap” between the arrow changing and the data moving. To SwiftUI, the entire TableConfig struct changes as a single value.
  • Single Render Pass: No double-hops. No flickering.
  • Native Animation: By using $config.sortOrder.animation() on the binding, the entire struct mutation is wrapped in a single transaction.

Sample A: Data passed in as a let

Use this when a parent view owns the data and passes it down. We use onAppear and onChange to keep our local projection in sync.

struct SampleA: View {
    let items: [Item] // Passed from parent
    @State private var config = TableConfig()

    var body: some View {
        Table(config.sortedItems, sortOrder: $config.sortOrder.animation()) {
            TableColumn("Date", value: \.timestamp) { item in
                Text(item.timestamp, format: .dateTime)
            }
        }
        .onAppear { config.items = items }
        .onChange(of: items) { config.items = items } 
    }
}

Sample B: Data from an Observable Store (onReceive)

This is the cleanest “surgical” implementation. Since onReceive fires on the initial subscription and every subsequent update, you don’t need onAppear or onChange.

struct SampleB: View {
    @ObservedObject var store: MyStore
    @State private var config = TableConfig()

    var body: some View {
        Table(config.sortedItems, sortOrder: $config.sortOrder.animation()) {
            TableColumn("Date", value: \.timestamp) { item in
                Text(item.timestamp, format: .dateTime)
            }
        }
        // One modifier to rule them all: handles initial load and updates
        .onReceive(store.$entries) { newEntries in
            config.items = newEntries
        }
    }
}

By coupling the sort logic directly to the property change inside the struct, you’ve moved from Reactive UI to Stable State Architecture. This is how you build a macOS app that feels truly native.

Categories: SwiftUI