若要插入建议而不应用文本框的值,请将补全与提交分开处理。在 macOS 15 或更高版本中,SwiftUI 提供原生文本建议;使用 submitScope() 阻止祖先视图的提交处理程序,并通过显式按钮执行最终操作。

本教程将在你自己的 Mac 应用中构建一个小型标签选择器。输入 Bl 时,会显示诸如 Blue Tile 这样的合成标签。选择建议会更新可编辑草稿;点击 应用标签 会将草稿复制到已应用值。这个区别很重要:阻止应用操作与截获 Return 键命令是两个不同的需求。

选择符合交互需求的 API

Apple 的 textInputSuggestions(_:) 修饰符可为文本框显示选项。每个可操作的建议都需要 textInputCompletion(_:),用来提供替换字符串。这两个 API 的文档均注明适用于 macOS 15 及更高版本。没有补全修饰符的建议可以显示,但无法响应点击。Apple:textInputSuggestions(_:)、Apple:textInputCompletion(_:)

对于简短的标签文本框,替换整个草稿是合适的。Apple 的建议文档介绍了这种整字段替换方式。不要假设此 API 提供相对于插入点的词条编辑器或行内后缀预览;这些功能需要更具体的编辑设计。Apple:textInputSuggestions(_:)

你的需求 合适的起点
在 macOS 15 或更高版本中提供完整标签 SwiftUI 原生文本建议
阻止文本框调用祖先视图的提交处理程序 在文本框上使用 submitScope()
使用自定义建议布局或支持更早的部署目标 由应用管理、可更新草稿绑定的建议按钮
让 Return 接受高亮项并截获该命令 由应用管理且带有命令处理功能的 AppKit 控件
预览后缀或替换插入点处的单词 AppKit 文本补全和显式补全范围

最后两行是根据文档中的命令和补全 API 得出的架构选择。当交互需要比替换字符串和提交边界更精细的控制时,这些选择会很有用。

构建完整的 SwiftUI 标签选择器

在 Xcode 中创建一个 macOS App 项目,并使用包含 macOS 15 API 的 SwiftUI SDK。将部署目标设为 macOS 15 或更高版本。将生成的应用和内容视图声明替换为以下单个源文件,并确保目标中只保留一个 @main 应用声明。

示例将 draft 和 appliedLabel 分开保存。示例中的本地匹配规则刻意保持简单:区分大小写的前缀匹配、文本为空时不提供建议、精确匹配时不显示建议。Swift 文档将 hasPrefix(_:) 定义为字符串前缀比较;更宽泛的匹配规则应由应用自行决定。Swift:Strings and Characters

import SwiftUI

@main
struct LabelCompletionApp: App {
    var body: some Scene {
        WindowGroup {
            LabelCompletionView()
        }
    }
}

struct LabelCompletionView: View {
    private let labels = [
        "Blue Tile",
        "Blue Tray",
        "Green Tile",
        "Round Tray"
    ]

    @State private var draft = ""
    @State private var appliedLabel = ""
    @State private var applyCount = 0
    @FocusState private var fieldIsFocused: Bool

    private var matches: [String] {
        guard !draft.isEmpty else { return [] }
        return labels.filter {
            $0.hasPrefix(draft) && $0 != draft
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Choose a label")
                .font(.headline)

            TextField("Type Bl to see suggestions", text: $draft)
                .textFieldStyle(.roundedBorder)
                .focused($fieldIsFocused)
                .textInputSuggestions {
                    ForEach(matches, id: \.self) { label in
                        Text(label)
                            .textInputCompletion(label)
                    }
                }
                .submitScope()

            Button("Apply Label") {
                appliedLabel = draft
                applyCount += 1
            }
            .disabled(draft.isEmpty)

            Text("Draft: \(draft)")
            Text("Applied: \(appliedLabel)")
            Text("Apply count: \(applyCount)")
        }
        .padding(24)
        .frame(width: 420)
        .onAppear {
            fieldIsFocused = true
        }
    }
}

这是一个完整的应用示例,包含示例标签和状态。我们已根据检索到的 API 文档检查了代码,但此处未编译或运行,因为环境中没有 Swift 工具链和 macOS UI 运行环境。

为什么选择建议不会应用标签

每个补全项都会向文本框提供一个字符串,而文本框绑定到 draft。只有 应用标签 按钮的操作会赋值给 appliedLabel 或递增 applyCount。因此,在此示例中,更新草稿不会触发最终操作。

文本框还使用了 submitScope()。Apple 的文档说明,此修饰符可阻止提交触发传递到视图层级中更高位置的提交修饰符。当该组件嵌入带有祖先 onSubmit 的大型表单时,这一点很重要。Apple:submitScope(_:)

初始焦点通过 @FocusState 设置。Apple 的文档说明,以编程方式为其绑定值赋值会将焦点移至关联视图。此处的焦点设置是为了方便编辑,不会控制是否应用标签。Apple:FocusState

示例有意允许的操作

标签列表提供的是建议,并不限制有效输入。输入 Square Tile 仍会启用 应用标签。如果界面只允许使用列表中的标签,请明确修改校验规则,例如仅当草稿与目录条目相符时才启用按钮。不要静默地将无法匹配的草稿替换为第一条建议。

示例还会替换整个标签。如果草稿包含多个单词,选择补全项应视为选择最终的字段值。需要保留周围文本的词条编辑器则需要依据选区进行替换。

理解“不提交”的含义

SwiftUI 的 onSubmit(of:_:) 文档说明,用户按下 Return 时,文本框会触发其提交操作。提交操作可以附加到单个视图,也可以附加到更大的视图层级。如果自动补全选择似乎触发了意外的应用操作,请检查这两处设置。Apple:onSubmit(of:_:)

submitScope() 为祖先处理程序设定边界。它的文档并未保证不会发生内部提交事件、编辑会持续进行,或所有键盘命令都会被截获。同样,Apple 对补全修饰符的说明是文本替换,并未承诺一律抑制 Return 键行为。选择交互方式时请牢记这些限制。Apple:submitScope(_:)、Apple:textInputCompletion(_:)

对于现有组件,请分别检查以下路径:

  1. 如果 Return 绝不能应用字段值,请从文本框自身的 onSubmit 中移除最终应用操作。
  2. 在文本框上添加 submitScope(),以阻止祖先提交操作。
  3. 单独检查按钮键盘快捷键,尤其是显式默认操作。
  4. 检查任何变更观察器或模型设置器,确认它们不会在草稿变化时执行最终操作。

空的本地 onSubmit 闭包并非文档所述的提交边界替代方案。Apple 特别提供了 submitScope() 来阻止事件传播。如果应用只应在没有活动补全项时响应 Return,则需要额外状态和命令处理来实现这种条件键盘策略。Apple:onSubmit(of:_:)

需要 Return 确认并截获时使用 AppKit

对于由应用管理的 NSTextField,AppKit 提供了 control(_:textView:doCommandBy:)。其文本视图参数是该控件的字段编辑器。代理可以处理绑定命令并返回 true;返回 false 则表示该代理未处理该命令。这是文档规定的自定义文本框命令扩展点。Apple:control(_:textView:doCommandBy:)

自定义补全控制器可以通过该扩展点按以下顺序处理:

  1. 跟踪建议列表是否打开,以及哪个项目处于高亮状态。
  2. 收到 Return 命令且存在有效的高亮项目时,将该项目插入草稿。
  3. 关闭建议列表并同步应用的草稿模型。
  4. 返回 true,因为控制器已处理该命令。
  5. 对控制器未处理的命令返回 false。

这套顺序是根据代理约定推导出的实现设计,并非内置的自动补全策略。还要单独决定列表关闭时 Return 应执行什么操作。如果你管理的是独立的 NSTextView,相应的钩子是 textView(_:doCommandBy:);Apple 指出,返回 true 会阻止文本视图自行执行该命令。Apple:textView(_:doCommandBy:)

对于文本补全本身,NSTextView.insertCompletion(_:forPartialWordRange:movement:isFinal:) 支持替换和选区变更。使用 isFinal: false 时,会选中建议的后缀;使用 isFinal: true 时,会在补全单词后折叠选区。这个标记描述的是补全状态,而非应用提交状态。movement 参数用于标识补全导航或选择的方式。Apple:insertCompletion(_:forPartialWordRange:movement:isFinal:)

保留正确的文本范围

AppKit 的 rangeForUserCompletion 用于标识从部分单词开头到插入点的范围。请使用符合预期替换内容的范围,并在编辑或插入点移动后重新计算。文本变更后不要盲目复用之前捕获的范围。Apple:rangeForUserCompletion

转换文本范围时,不要用 Swift 的字符数代替 NSString 长度。Swift 文档解释了 String 统计扩展字素簇,而 NSString 长度统计 UTF-16 代码单元。这些值可能不同。SwiftUI 示例采用整字段绑定替换,因此完全避免了这种范围转换。Swift:Strings and Characters)

分别验证插入和应用

请通过可见的草稿、已应用值和计数器,检查每个受支持 macOS 版本上的结果。以下是针对该示例的预期检查项,并非已报告的测试结果。

操作 预期的可观察结果
输入 Bl 草稿为 Bl;显示匹配标签建议
点击 Blue Tile 草稿变为 Blue Tile;已应用值和计数保持不变
编辑时按 Return 应用不会应用草稿
点击 应用标签 已应用值复制草稿;计数增加一次
之后编辑草稿 之前已应用的值仍然可见
清空草稿 建议列表为空,且 应用标签 已禁用
输入 bl 根据示例的区分大小写规则,没有匹配项

还要在原生建议列表中通过键盘选择,并将文本框嵌入其实际父视图后重复这些检查。Apple 指出,移动 macOS 窗口等事件可能会关闭原生建议界面,因此即使没有该列表,草稿仍应可用。Apple:textInputSuggestions(_:)

如果部署目标早于 macOS 15,请提供经过可用性检查的原生视图和回退方案,或始终使用由应用管理的建议按钮。回退按钮可以直接将标签赋值给草稿,并将最终操作留给 应用标签。这样可以实现同样的草稿与应用操作分离,而键盘导航和显示逻辑仍由你的组件负责。

对于这个标签选择器,界限可以通过可观察结果来确认:接受补全会更改可编辑草稿,只有显式的最终操作会更改已应用标签。使用 SwiftUI 原生建议和提交范围来实现这个边界;如果需求还包括截获 Return 本身,则改用 AppKit 命令处理。

来源