如何修复macOS自定义ComboBox绑定失效问题
我开发了一款macOS SwiftUI应用,包含一组Station对象列表,选中其中一个对象时,详情视图会展示该对象的信息。此功能运行正常,详情视图能正确显示对应信息,但使用自定义ComboBox更新字段时,更新的却是之前选中的对象;而使用TextField则可以正确更新当前选中对象的属性。
推测ComboBox的绑定存在问题,它似乎没有刷新,仍在引用之前选中的对象。请问该如何修复这个问题?
列表对象类
class Station: Identifiable, Equatable, ObservableObject { static func == (lhs: Station, rhs: Station) -> Bool { lhs.id == rhs.id } @Published var id: String @Published var isSelected: Bool = false @Published var location: CLLocationCoordinate2D @Published var os: String @Published var installation: String @Published var installer: String = "" { didSet { print("\(id) didSet installer: \(installer)") Task { await controller.saveStationInstaller(station: self) } } } @Published var targetDate: String = "" @Published var installDate: String = "" @Published var notes: String = "" @Published var group: String = "" let controller: RolloutDBController init(id: String, lat: Double, lon: Double, os: String, installation: String, installer: String, targetDate: String, installDate: String, notes: String, group: String, controller: RolloutDBController){ self.id = id self.location = CLLocationCoordinate2D( latitude: lat, longitude: lon) self.os = os self.installation = installation self.installer = installer self.targetDate = targetDate self.installDate = installDate self.notes = notes self.group = group self.controller = controller } }
列表视图
var stationList: some View { VStack { Text("Stations").onTapGesture { self.expandStations.toggle() } Divider() ScrollViewReader { proxy in ScrollView(.vertical) { ForEach(dbController.stations, id: \.id){ station in StationListRow(station: station, expandAll: expandStations) .id(station.id) .padding(3) .background(station.isSelected ? Color.gray : station.color.opacity(0.25)) .onTapGesture { dbController.selectStation(station: station) }.padding(.horizontal, 3) } }.padding(.bottom, 6) .onChange(of: dbController.selectedStation, perform: { _ in if let st = dbController.selectedStation { proxy.scrollTo(st.id) } }) } TextField("search", text: $dbController.searchText).onSubmit { Task { dbController.deselect() await dbController.getStations() } } }.padding(.bottom, 6) }
详情视图
注:"new station xxx"仅在首次选中对象,或取消选中当前对象后再选中另一个对象时才会出现,说明视图在连续选中对象时会被保留。
struct StationDetails: View { @ObservedObject var station: Station @ObservedObject var dbController: RolloutDBController var body: some View { VStack(alignment: .leading, spacing: 6) { Text(station.id).frame(maxWidth: .infinity, alignment: .leading).onAppear { print("new station \(station.id)") } Text(station.installation).frame(maxWidth: .infinity, alignment: .leading) Divider() VStack{ TextField("os", text: $station.os).frame(maxWidth: .infinity, alignment: .leading) Divider() ComboBox(items: dbController.groups.map({$0.name}), text: $station.group) ComboBox(items: dbController.installers.map({$0.name}), text: $station.installer) Divider() } VStack{ ComboBox(items: dbController.targetDates.map({$0.name}), text: $station.targetDate) ComboBox(items: dbController.installDates.map({$0.name}), text: $station.installDate) Divider() TextEditor(text: $station.notes).frame(minHeight: 200) } Divider() Button("Save", action: { Task { await dbController.save(station: station) } }) }.padding(10) } }
自定义ComboBox视图(疑似问题所在)
当从列表中选中新对象时,绑定未正确设置。
struct ComboBox: NSViewRepresentable { // The items that will show up in the pop-up menu: var items: [String] // The property on our parent view that gets synced to the current stringValue of the NSComboBox, whether the user typed it in or selected it from the list: @Binding var text: String func makeCoordinator() -> Coordinator { return Coordinator(self) } func makeNSView(context: Context) -> NSComboBox { let comboBox = NSComboBox() comboBox.usesDataSource = false comboBox.completes = false comboBox.delegate = context.coordinator comboBox.intercellSpacing = NSSize(width: 0.0, height: 10.0) // Matches the look and feel of Big Sur onwards. return comboBox } func updateNSView(_ nsView: NSComboBox, context: Context) { nsView.removeAllItems() nsView.addItems(withObjectValues: items) // ComboBox doesn't automatically select the item matching its text; we must do that manually. But we need the delegate to ignore that selection-change or we'll get a "state modified during view update; will cause undefined behavior" warning. context.coordinator.ignoreSelectionChanges = true nsView.stringValue = text nsView.selectItem(withObjectValue: text) context.coordinator.ignoreSelectionChanges = false } } // MARK: - Coordinator extension ComboBox { class Coordinator: NSObject, NSComboBoxDelegate { var parent: ComboBox var ignoreSelectionChanges: Bool = false init(_ parent: ComboBox) { self.parent = parent } func comboBoxSelectionDidChange(_ notification: Notification) { if !ignoreSelectionChanges, let box: NSComboBox = notification.object as? NSComboBox, let newStringValue: String = box.objectValueOfSelectedItem as? String { parent.text = newStringValue } } func controlTextDidEndEditing(_ obj: Notification) { if let textField = obj.object as? NSTextField { parent.text = textField.stringValue } } } }
问题根源在于自定义ComboBox的Coordinator持有了旧的绑定引用,当StationDetails视图被复用(连续选中不同Station时视图不重建),ComboBox的绑定没有正确切换到新的Station属性。以下两种方法均可解决:
方法一:强制SwiftUI重建ComboBox
在StationDetails中使用ComboBox时,添加.id(station.id)标识,每次选中新Station时强制SwiftUI重建ComboBox,确保绑定指向当前Station的属性:
ComboBox(items: dbController.groups.map({$0.name}), text: $station.group) .id(station.id) ComboBox(items: dbController.installers.map({$0.name}), text: $station.installer) .id(station.id) // 其余所有ComboBox均需添加该标识
方法二:修复Coordinator的绑定逻辑
修改ComboBox和Coordinator,让Coordinator直接持有绑定引用而非依赖parent,确保更新时绑定能同步切换:
struct ComboBox: NSViewRepresentable { var items: [String] @Binding var text: String func makeCoordinator() -> Coordinator { Coordinator(text: $text) } func makeNSView(context: Context) -> NSComboBox { let comboBox = NSComboBox() comboBox.usesDataSource = false comboBox.completes = false comboBox.delegate = context.coordinator comboBox.intercellSpacing = NSSize(width: 0.0, height: 10.0) return comboBox } func updateNSView(_ nsView: NSComboBox, context: Context) { nsView.removeAllItems() nsView.addItems(withObjectValues: items) context.coordinator.ignoreSelectionChanges = true nsView.stringValue = text nsView.selectItem(withObjectValue: text) context.coordinator.ignoreSelectionChanges = false // 更新Coordinator的绑定引用 context.coordinator.text = $text } } extension ComboBox { class Coordinator: NSObject, NSComboBoxDelegate { @Binding var text: String var ignoreSelectionChanges: Bool = false init(text: Binding<String>) { _text = text } func comboBoxSelectionDidChange(_ notification: Notification) { if !ignoreSelectionChanges, let box = notification.object as? NSComboBox, let newStringValue = box.objectValueOfSelectedItem as? String { text = newStringValue } } func controlTextDidEndEditing(_ obj: Notification) { if let textField = obj.object as? NSTextField { text = textField.stringValue } } } }
为什么TextField能正常工作?
SwiftUI原生TextField会自动处理绑定的更新,视图复用时会同步切换绑定指向的对象属性;而自定义NSViewRepresentable组件需要手动处理绑定的更新逻辑,否则Coordinator会一直持有旧的绑定引用。
内容的提问来源于stack exchange,提问作者Duncan Groenewald

