指定 NSTextFieldDelegate,实现 controlTextDidEndEditing(_:),并将字段的 stringValue 复制到 SwiftUI 绑定中。协调器将该回调连接到视图状态,以便标签在编辑完成后更新。

本教程构建一个包含两个可编辑标签和一个显示其已提交值的摘要的小型 Mac 应用。任务是在用户输入时保持摘要不变,然后在 AppKit 结束编辑会话时更新它。Apple 将结束编辑回调记录为在控件提交其更改后发生。Apple 的 controlTextDidEndEditing(_:) 文档

哪个事件应该更新视图?

对于现有的 NSTextField,使用其控件编辑委托回调。委托接收到的通知其 object 为发布它的控件,使处理程序能够识别字段并读取其值。Apple 的 textDidEndEditingNotification 文档

根据你需要的界面行为选择事件:

期望行为 适当路径
当 AppKit 文本字段结束编辑时更新已提交的值 在其委托上实现 controlTextDidEndEditing(_:)。
在编辑完成时发送现有的 AppKit 目标-动作处理程序 配置单元格的 sendsActionOnEndEditing 属性。
处理来自原生 SwiftUI TextField 的提交 为其记录的提交行为附加 .onSubmit。

这些路径有不同的契约。Apple 记录了启用 sendsActionOnEndEditing 会在按 Return、Tab 离开字段或点击另一个文本字段后发送动作;禁用它将动作限制为仅 Return。SwiftUI 将 TextField 的 .onSubmit 记录为响应 Return。因此,它并未为嵌入的 AppKit 字段建立通用的焦点丢失回调。Apple 的 sendsActionOnEndEditing 文档,Apple 的 onSubmit(of:_:) 文档

对于此应用,委托是提交字段文本的单一位置。如果你稍后添加目标-动作处理程序,请避免在两个处理程序中执行相同的提交副作用。

构建完整的 SwiftUI 和 AppKit 示例

在 Xcode 中使用 Swift 和 SwiftUI 界面创建一个 macOS 应用项目。使用支持 Swift 6 的近期工具链,并将部署目标设置为 macOS 13 或更高版本。用下面的完整程序替换生成的应用入口文件,如果生成的 ContentView.swift 声明了另一个 ContentView,则将其移除。保留恰好一个 @main 入口点。Apple 的 App 文档描述了此入口点结构。Apple 的 App 文档

示例的 macOS 13 目标是项目选择,而非桥接的引入版本:Apple 将 NSViewRepresentable 列为从 macOS 10.15 开始可用。AppKit 使此实现特定于 macOS 目标。Apple 的 NSViewRepresentable 文档

import SwiftUI
import AppKit

@main
@MainActor
struct EditingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@MainActor
struct ContentView: View {
    @State private var cardTitle = "Blue Card"
    @State private var shelfLabel = "Shelf A"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Card title")
            CommitTextField(value: $cardTitle, label: "Card title")
                .frame(height: 24)

            Text("Shelf label")
            CommitTextField(value: $shelfLabel, label: "Shelf label")
                .frame(height: 24)

            Divider()

            Text("Committed values")
                .font(.headline)
            Text("Card: \(cardTitle)")
            Text("Shelf: \(shelfLabel)")
        }
        .padding(20)
        .frame(minWidth: 420)
    }
}

@MainActor
struct CommitTextField: NSViewRepresentable {
    @Binding var value: String
    let label: String

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> NSTextField {
        let field = NSTextField(string: value)
        field.placeholderString = label
        field.delegate = context.coordinator
        return field
    }

    func updateNSView(_ field: NSTextField, context: Context) {
        context.coordinator.parent = self
        field.placeholderString = label
        field.isEnabled = context.environment.isEnabled

        // Leave the active editing buffer alone.
        if field.currentEditor() == nil, field.stringValue != value {
            field.stringValue = value
        }
    }

    static func dismantleNSView(
        _ field: NSTextField,
        coordinator: Coordinator
    ) {
        field.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: CommitTextField

        init(parent: CommitTextField) {
            self.parent = parent
            super.init()
        }

        func controlTextDidEndEditing(_ notification: Notification) {
            guard let field = notification.object as? NSTextField else {
                return
            }

            let committedValue = field.stringValue
            if parent.value != committedValue {
                parent.value = committedValue
            }
        }
    }
}

这是一个带有合成本地值的完整应用示例。它已根据检索到的 API 文档进行了检查,但未在此编译或运行:可用的执行环境是 Linux,没有 Swift 工具链或 macOS SDK。请在 Mac 上使用 Xcode 构建并运行它。

值如何到达摘要

每个父级 @State 值向其字段包装器提供一个绑定。当编辑结束时,协调器通过该绑定写入。SwiftUI 随后因读取到状态变化而更新摘要。Apple 同时记录了状态驱动的视图更新和使用绑定让子视图修改父级拥有的状态。Apple 的 State 文档

makeNSView 创建可编辑的 AppKit 控件并将协调器指定为其委托。updateNSView 处理从 SwiftUI 流向该控件的变化。委托回调处理反方向。Apple 的集成指南对托管 AppKit 视图使用相同的协调器和绑定安排。Apple 的“Use SwiftUI with AppKit”会话

赋值 context.coordinator.parent = self 保持协调器存储的 representable 为当前值。协调器在托管视图的生命周期内存活,而 representable 的值可能会改变。Apple 明确演示了在 updateNSView 期间刷新该存储值。Apple 的协调器生命周期解释

在 SwiftUI 更新期间保留草稿

关键防护是 field.currentEditor() == nil。此示例有意将未完成的文本保留在 AppKit 中,而 SwiftUI 保留最后提交的值。如果没有编辑防护,在 SwiftUI 更新期间将已提交的值复制到字段中可能会替换未完成的草稿。

Apple 记录了 currentEditor() 返回控件的字段编辑器,或在它没有时返回 nil。字段编辑器是窗口中控件共享的 NSTextView。这就是为什么仅检查窗口的 firstResponder 是否等于 NSTextField 是询问控件其编辑器的不良替代品。Apple 的 currentEditor() 文档

由此产生的行为是一个明确的设计策略:

情况 示例行为
字段空闲且其绑定改变 在下一次 representable 更新时将绑定复制到字段。
字段有活动编辑器 保留编辑缓冲区。
编辑完成且文本不同 将完成的文本写入绑定。
编辑完成但文本未变 跳过冗余的状态赋值。

此策略在编辑会话期间赋予本地草稿优先权。如果其他应用代码在用户编辑时更改相同的绑定,稍后的提交可能会替换该更改。演示没有竞争写入者。在更大的编辑器中,决定如何解决并发更改——例如,在切换选中项前完成当前会话,或保留单独的草稿和已提交值并提供显式选择。不要仅为了同步重绘而丢弃未完成的文本。

此任务不需要 TextKit 布局自定义。相关边界是控件的编辑会话及其字段编辑器。Apple 的文本架构指南解释了 NSTextField、其委托和共享的 NSTextView 字段编辑器之间的关系;该指南已归档,而当前的 currentEditor() 参考确认了共享编辑器模型。Apple 的“Text Editing”指南,Apple 的当前字段编辑器参考

在应用中验证完成行为

使用以下序列在构建后检查示例。这些是根据实现和记录的编辑行为得出的预期检查,而非报告的执行结果。

  1. 将 Blue Card 替换为 Green Card。输入时,摘要仍应显示 Card: Blue Card。
  2. 按 Return。摘要应变为 Card: Green Card。
  3. 再次编辑卡片标题,然后点击 Shelf label 字段。完成的卡片标题应出现在摘要中。
  4. 编辑货架值,然后按 Tab。确认货架摘要更新并检查键盘导航。
  5. 提交空值。相应摘要应显示其前缀且无值;示例接受空字符串。
  6. 在不改变文本的情况下完成编辑。相等性检查应避免另一次状态赋值。

Return 可以在不产生你预期的焦点移动的情况下完成编辑。Apple 记录了在其结束编辑通知后,NSTextField 会尝试其动作和其他 Return 处理,并可能选中其文本。Tab 和 Shift-Tab 尝试导航到下一个或上一个关键视图。检查界面时,将完成和焦点目标视为独立行为。Apple 的 textDidEndEditing(_:) 文档

诊断未到达的回调

首先,验证 field.delegate 仍引用协调器。该属性是弱引用,因此没有独立所有者的临时委托对象将无法保持可用。representable 协调器提供了此处所需的生命周期。Apple 的 delegate 文档,Apple 的协调器生命周期解释

接下来,确认编辑确实已结束。点击任意背景不是测试此条件的可靠方法。在此演示中使用 Return 或另一个可编辑字段,如果结果不同,请检查特定的响应者转换。

如果你的应用添加了验证,请检查 control(_:textShouldEndEditing:)。其记录的返回值决定编辑是否可以结束。离开字段的被拒绝尝试不应视为成功提交。Apple 的 control(_:textShouldEndEditing:) 文档

还要检查取消路径。Apple 明确指出,调用 abortEditing() 丢弃待处理的编辑不会调用 controlTextDidEndEditing(_:)。因此取消操作需要其自身的刻意状态行为。Apple 的结束编辑回调文档

从显式应用命令请求完成

对于将来拥有对此字段引用的 Done 命令,支持的响应者路径是请求其自己的窗口调用 makeFirstResponder(nil)。在继续前检查返回的布尔值:Apple 记录了当前响应者可以拒绝放弃响应者身份,导致该方法返回 false。使用字段的实际窗口,而不是假设应用范围的关键窗口属于此编辑器。Apple 的 makeFirstResponder(_:) 文档

将此桥接的 AppKit 访问和 UI 回调保持在主 actor 上。当前委托声明是主 actor 隔离的,Swift 将 MainActor 记录为使用等同于主调度队列的执行器。示例直接用 @MainActor 表达该隔离;它不会在完成和绑定写入之间添加延迟任务。Apple 的回调声明,Swift 的 MainActor 文档

实现有一个清晰的提交边界:AppKit 完成编辑,协调器读取字段,绑定更新依赖的 SwiftUI 文本。在 representable 更新期间保护草稿使该边界对日常标签编辑有用,无需自定义文本布局机制。

来源