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.
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:
@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.
@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.
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()
}
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()
}