The Quest for Butter-Smooth Table Sorting on macOS
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
bodyto 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:
sortOrderupdates immediately.- SwiftUI triggers a render. The
Tablesees the new sort arrow but the old, unsorted data. It tries to animate, but the data doesn’t match the expectation. - Then
.onChangefires, 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
TableConfigstruct 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.