Search code examples
macosswiftuitableviewselection

How to select SwiftUI Table item on single click?


I have a SwiftUI Table on macOS. (I'm NOT using a List.) I want it to behave the same as a Cocoa table: when a user clicks on a row, focus should shift to the table AND the clicked row should be selected. Right now, the table is gaining focus after one click, but the selection isn't being set. It takes another click for that to happen. Quite annoying.

    Table(tableModel.roads, selection: $tableModel.selectedRoadID) {
        TableColumn("ED") { road in
            Text(road.district)
        }
        .width(35)
        
        TableColumn("Road") { road in
            Text(road.name)
        }
    }
    .frame(height: nil) // Adjust height as needed

I tried adding .focused and an .onTapGesture setting the focus state, but the behavior didn't change.

    @FocusState private var roadsTableFocused: Bool

// ...

    .focused($roadsTableFocused)
    .onTapGesture {
        roadsTableFocused = true
    }

Thanks in advance for your help!


Update: Thanks to Sweeper for the question. This has something to do with the @Observable model class I'm using. When I use a @State variable in the same View class for the TextField, it works fine. Same when I switch to the table from any non-TextField widgets, even though they're also bound to state variables in the model class. Here's an MRE of the issue in the form of a macOS SwiftUI Playground application:

ContentView.swift

import AppKit
import SwiftUI

struct Road: Identifiable {
    let id: Int
    let name: String
    let district: String
}

@Observable class TableModel {
    var observableFieldValue: String = ""
}

struct ContentView: View {
    @State private var tableModel: TableModel
    @State private var selectedRoadID: Road.ID?
    @State private var localFieldValue: String = ""
    @State private var roads: [Road] = [
        Road(id: 1, name: "Market", district: "5"),
        Road(id: 2, name: "Howard", district: "5"),
        Road(id: 3, name: "Gough", district: "5"),
        Road(id: 4, name: "Click Here", district: "5"),
    ]

    init() {
        let viewModel = TableModel()
        _tableModel = State(wrappedValue: viewModel)
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            TextField("Observable binding", text: $tableModel.observableFieldValue)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            TextField("ContentView binding", text: $localFieldValue)
                .textFieldStyle(RoundedBorderTextFieldStyle())

            Table(roads, selection: $selectedRoadID) {
                TableColumn("ED") { road in
                    Text(road.district)
                }
                .width(35)
                
                TableColumn("Road") { road in
                    Text(road.name)
                }
            }
        }
        .frame(width: 300, height: 220, alignment: .leading)
    }
}

#Preview {
    ContentView()
}

Update 2: I've made a video of what I'm seeing. I'm on macOS 15.2 (24C101) using Xcode 16.2 (16C5032a). I've built a full test application using the above code, slightly modified from before to remove the Playground.

Is it really the case that no one else is seeing this behavior? 🤔 It's 100% reproducible for me.


Solution

  • There seems to be indeed a difference in how the table selection responds when it gains focus after switching from a TextField that has the text property bound to an observable property, rather than a local state.

    From what I understand so far:

    1. When using a local @State as binding for the TextField:

    When focus changes, SwiftUI updates the state synchronously without waiting for external dependencies (like the observable property). So the focus change completes before the tap gesture is handled by the table row, allowing the row selection to occur immediately with one tap.

    1. When using an @Observable object as binding for the TextField:

    When focus changes, SwiftUI triggers a data update to propagate the change (and notify the observable object of the focus change update) and the state update happens asynchronously. This causes the first tap to clear the textfield focus during the first update/render cycle, but not also set the row selection during the same cycle.

    You can verify this by adding the @ObservationIgnored wrapper to tableModel.observableFieldValue, and you'll notice that table row selection will require only one tap after switching from any text field:

    @Observable class TableModel {
        
        @ObservationIgnored var observableFieldValue: String = ""
        var selectedRoadID: Road.ID?
        
    }
    

    Depending on your app logic and the role of the observableFieldValue, additional logic may need to be added to make up for the loss of observation. However, the values will update on subsequent view updates.

    Since the problem seems to be caused by the delay introduced by the observation update process, the solution is to avoid using it by taking control of when updates happen.

    The fix - Using local states:

    Given that the Table selection works well when text fields are bound to local states, use local states for the text fields and manually update the local states when observed values change:

    
    import AppKit
    import SwiftUI
    
    struct Road: Identifiable {
        let id: Int
        let name: String
        let district: String
    }
    
    
    @Observable class TableModel {
        
        var observableFieldValue: String = ""
        var selectedRoadID: Road.ID?
    }
    
    struct TableSelectionTestView: View {
        
        @Bindable var tableModel = TableModel()
        
        @State private var selectedRoadID: Road.ID?
        @State private var localFieldValue: String = ""
        @State private var anotherLocalFieldValue: String = ""
        
        @State private var roads: [Road] = [
            Road(id: 1, name: "Market", district: "5"),
            Road(id: 2, name: "Howard", district: "5"),
            Road(id: 3, name: "Gough", district: "5"),
            Road(id: 4, name: "Click Here", district: "5"),
        ]
        
        
        @State private var selectedRoadName: String?
        
        var body: some View {
            VStack(alignment: .leading, spacing: 8) {
                
                VStack(alignment: .leading, spacing: 10) {
                    
                    TextField("Observable value via local state", text: $anotherLocalFieldValue)
                    
                    TextField("ContentView binding", text: $localFieldValue)
    
                    Text("Selected road in table: \(selectedRoadName ?? "None")")
                        .foregroundStyle(selectedRoadID == nil ? Color.secondary : Color.green)
                }
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
                
                //Table
                Table(roads, selection: $tableModel.selectedRoadID) {
                    TableColumn("ED") { road in
                        Text(road.district)
                    }
                    .width(35)
                    
                    TableColumn("Road") { road in
                        Text(road.name)
                    }
                }
            }
            .frame(width: 300, height: 350, alignment: .leading)
            .onChange(of: tableModel.selectedRoadID){
                selectedRoadID = tableModel.selectedRoadID
                
                if let roadID = selectedRoadID, let road = roads.first(where: { $0.id == roadID }) {
                    anotherLocalFieldValue = road.name
                    selectedRoadName = road.name
                }
                else {
                    anotherLocalFieldValue = ""
                    selectedRoadName = "None"
                }
            }
        }
    }
    
    #Preview {
        TableSelectionTestView()
    }
    

    UPDATE:

    Here's a revised code that uses a custom Binding for the observable text field and additional .onChange logic to update the observable field value both ways:

    
    import AppKit
    import SwiftUI
    
    struct Road: Identifiable {
        let id: Int
        let name: String
        let district: String
    }
    
    
    @Observable class TableModel {
        
        var observableFieldValue: String = ""
        var selectedRoadID: Road.ID?
    }
    
    struct TableSelectionTestView: View {
        
        @Bindable var tableModel = TableModel()
        
        @State private var selectedRoadID: Road.ID?
        @State private var localFieldValue: String = ""
        @State private var anotherLocalFieldValue: String = ""
        
        @State private var roads: [Road] = [
            Road(id: 1, name: "Market", district: "5"),
            Road(id: 2, name: "Howard", district: "5"),
            Road(id: 3, name: "Gough", district: "5"),
            Road(id: 4, name: "Click Here", district: "5"),
        ]
        
        
        @State private var selectedRoadName: String?
        
        var body: some View {
            VStack(alignment: .leading, spacing: 8) {
                
                VStack(alignment: .leading, spacing: 10) {
                    
                    TextField("Observable value via local state", text: Binding(
                        get: {
                            tableModel.observableFieldValue
                        },
                        set: { newValue in
                            anotherLocalFieldValue = newValue
                        })
                    )
                    
                    TextField("ContentView binding", text: $localFieldValue)
    
                    Text("Selected road in table: \(selectedRoadName ?? "None")")
                        .foregroundStyle(selectedRoadID == nil ? Color.secondary : Color.green)
                    
                    Text("Observable field value: \(tableModel.observableFieldValue)")
                    
                    Button {
                        tableModel.observableFieldValue = "Some string for field value..."
                    } label: {
                        Text("Set observable value")
                    }
                }
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()
                
                //Table
                Table(roads, selection: $tableModel.selectedRoadID) {
                    TableColumn("ED") { road in
                        Text(road.district)
                    }
                    .width(35)
                    
                    TableColumn("Road") { road in
                        Text(road.name)
                    }
                }
            }
            .frame(width: 300, height: 350, alignment: .leading)
            .onChange(of: tableModel.selectedRoadID){
                selectedRoadID = tableModel.selectedRoadID
                
                if let roadID = selectedRoadID, let road = roads.first(where: { $0.id == roadID }) {
                    anotherLocalFieldValue = road.name
                    selectedRoadName = road.name
                    tableModel.observableFieldValue = road.name
                }
                else {
                    anotherLocalFieldValue = ""
                    selectedRoadName = "None"
                }
            }
            .onChange(of: anotherLocalFieldValue){
                tableModel.observableFieldValue = anotherLocalFieldValue
            }
        }
    }
    
    #Preview {
        TableSelectionTestView()
    }