You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何修复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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 12:02:21