对于包含单行可编辑行的 Mac 应用,使用 onSubmit 处理 Return,在当前行下方立即插入一个新模型,并通过 FocusState 聚焦其文本框。为每一行分配稳定 ID,使插入操作能够保持文本、行和焦点之间的关联。
本教程将构建一个小型本地列表编辑器,其中包含 Item A 之类的示例条目。按下 Return 会在正在编辑的文本框下方新建一个空行,保留现有文本,并将键盘焦点移至新文本框。它不会在插入点处分割文本。
哪种文本控件支持此行为?
如果每一行代表一条简短条目,请使用单行 TextField。Apple 明确说明,用户按下硬件或软件 Return 键时,TextField 会触发其 onSubmit 操作。Apple 还说明,将某个 FocusState 值赋给关联视图即可移动焦点。这两个 API 均适用于 macOS 12 及更高版本。Apple:onSubmit(of:_:),Apple:FocusState。
根据用户正在编辑的内容选择控件:
| 编辑任务 | 起始控件 | 要实现的 Return 行为 |
|---|---|---|
| 每行一条简短条目 | 单行 TextField |
提交,在下方插入,并聚焦新行 |
| 每行一个段落 | TextEditor 或 AppKit 文本视图 |
明确插入行与插入换行符有何不同 |
| 带有自定义文本命令的文档 | 嵌入 SwiftUI 的 NSTextView |
处理相关文本命令并协调模型变更 |
Apple 将 TextEditor 描述为多行、可滚动的编辑器。其文档并未确立与 TextField 所述相同的按 Return 提交约定。因此,将示例改为 TextEditor 时需要重新审视命令处理;仅添加相同修饰符并不是文档所述的等效做法。Apple:TextEditor,Apple:onSubmit(of:_:)。
将行身份与行位置分开
三个值各有不同用途:行的 ID 用于标识条目,数组索引用于确定插入位置,焦点 ID 用于标识应该接收键盘输入的文本框。
Apple 的 ForEach 根据带标识的数据构建视图。请使用 Identifiable 模型,并在创建行时存储 ID。在此设计中,同一个 ID 也用作该行的焦点值。这样,在另一条目上方插入条目时,原条目仍能保留其身份。Apple:ForEach。
避免使用可编辑文本作为 ID:两个空行会具有相同身份,而且编辑文本会改变身份。对于此插入任务,数组位置也不适合作为身份,因为位置会发生变化。这些是所提议数据模型带来的问题,并非额外的 SwiftUI 要求。
每个文本框都需要不同的焦点值。Apple 提醒,将相同焦点值绑定到多个视图会造成焦点绑定不明确。为每行存储一个 UUID,就能简单地区分各个文本框。Apple:FocusState。
构建完整的 macOS 示例
在 Xcode 中使用 SwiftUI 创建一个 macOS App 项目,并将其部署目标设为 macOS 12 或更高版本。将生成的应用入口点和内容视图替换为以下单文件代码,并移除原有应用声明,使目标中只包含一个 @main 入口点。
此示例在滚动视图内使用普通 VStack。它会将新行记录为待处理的焦点目标,然后在该文本框出现时请求焦点。这是根据 Apple 的焦点和外观 API 得出的实现策略;Apple 并未将外观回调描述为普遍适用的焦点就绪保证。onAppear 的确切时机取决于视图。Apple:onAppear(perform:)。
import SwiftUI
import Foundation
import Dispatch
private struct EditableRow: Identifiable {
let id = UUID()
var text: String
}
@main
struct RowEntryDemoApp: App {
var body: some Scene {
WindowGroup {
RowEditor()
}
}
}
private struct RowEditor: View {
@State private var rows = [
EditableRow(text: "Item A"),
EditableRow(text: "Item B")
]
@State private var pendingFocusID: UUID?
@FocusState private var focusedRowID: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Local row editor")
.font(.headline)
Text("Press Return in a field to insert a row below it.")
.font(.subheadline)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 10) {
ForEach($rows) { $row in
TextField("New item", text: $row.text)
.textFieldStyle(.roundedBorder)
.focused($focusedRowID, equals: row.id)
.onSubmit {
insertRow(after: row.id)
}
.id(row.id)
.onAppear {
focusIfPending(row.id) {
proxy.scrollTo(row.id, anchor: .center)
}
}
}
}
.padding(4)
}
}
}
.padding(20)
.frame(minWidth: 420, minHeight: 280)
}
private func insertRow(after id: UUID) {
guard pendingFocusID == nil,
let index = rows.firstIndex(where: { $0.id == id })
else { return }
let newRow = EditableRow(text: "")
pendingFocusID = newRow.id
rows.insert(newRow, at: index + 1)
}
private func focusIfPending(
_ id: UUID,
scroll: @escaping () -> Void
) {
guard pendingFocusID == id else { return }
DispatchQueue.main.async {
guard pendingFocusID == id,
rows.contains(where: { $0.id == id })
else { return }
scroll()
focusedRowID = id
pendingFocusID = nil
}
}
}
模型包含所有示例数据,文件也定义了所有引用的类型和函数。研究环境中没有 Swift 编译器和 macOS 运行环境,因此这里只检查了程序,并未编译或运行。
插入与焦点如何配合
文本框的字符串绑定会在用户编辑时更新对应行。因此,提交处理程序可以插入新行,而不会替换或清空当前条目。Apple 说明,绑定字符串的文本框会持续更新。Apple:TextField。
插入处理程序通过 ID 找到提交操作对应的行,并在 index + 1 处插入。对于中间行,新条目会位于下一个现有条目之前。对于最后一行,该位置等于数组的 endIndex,这是有效的插入位置,因此会将元素追加到末尾。Apple:insert(_:at:)。
新文本框的外观回调会检查其 ID 是否与待处理请求匹配。派发的代码块会在滚动和设置焦点之前再次检查。这样可以避免无关的外观回调消耗该请求。示例还会在焦点请求待处理时忽略其他提交操作。
额外的派发会将操作延后到外观回调之外;它并不是文档所述的渲染屏障。请在支持的 macOS 版本和实际容器层级中验证这一调度选择。
滚动和焦点是两个独立操作。scrollTo 会按视图标识符定位子视图并滚动至其附近;赋值给 focusedRowID 则会请求键盘焦点。显式的 .id(row.id) 用于提供滚动目标。Apple:scrollTo(_:anchor:)。
检查结果并诊断常见故障
请使用示例条目按以下步骤验收:
- 点按
Item A,输入更多文本,然后按一次Return。 - 确认两个原有条目之间出现一个空文本框。
- 立即输入
Item C,并确认它出现在新文本框中。 - 在最后一个文本框中按下
Return,并确认末尾出现新行。 - 在空文本框中按下
Return,并确认允许再新建一个空行。 - 继续添加,直到需要滚动,然后确认插入的文本框变为可见并能接收输入。
这些是需要在本地验证的预期结果。此示例有意允许空条目,并保留当前字符串的全部内容,即使插入点位于字符串中间也是如此。如果任务要求将该字符串拆成两个条目,则还需要处理选区和文本范围。
如果行已出现,但输入仍留在旧文本框中,请检查焦点 ID、新文本框的外观回调,以及窗口是否处于活动状态。同时检查是否有其他回调重置了焦点。计算视图时不要重新生成行 ID。
如果发生了两次插入,请检查视图层级中更高位置的提交处理程序。Apple 允许在单个控件和包含视图上使用 onSubmit,并将 submitScope 说明为一种停止提交操作向更高层处理程序传播的方法。请确保插入操作只由一个处理程序负责。Apple:onSubmit(of:_:)。
更改提交标签并不能实现导航或插入。Apple 将 submitLabel 定义为设置语义提交标签。模型变更和焦点赋值仍需各自的代码来实现。Apple:submitLabel(_:)。
对于规模大得多的集合,请重新评估急切加载的堆栈。Apple 指出,List 和 LazyVStack 等容器会惰性查询 ForEach 元素。因此,屏幕外的文本框可能需要先滚动到视图中,其外观回调才能完成焦点请求。替换容器前,请先测试这一流程。Apple:ForEach。
何时应改用 AppKit 和 TextKit?
对于简短且彼此独立的条目,请使用 SwiftUI 示例。如果行中包含多行文本,或需要精确控制文本命令,则可以通过 NSViewRepresentable 嵌入自己的 NSTextView。Apple 将 Representable 描述为在 SwiftUI 中创建和更新 AppKit 视图的桥接方式,并可通过协调器转发委托交互。Apple:NSViewRepresentable。
对于这种替代方案,保留相同的行 ID 模型并实现以下流程:
- 创建文本视图,并将协调器指定为其委托。
- 将文本变更转发到对应行的模型。
- 实现
textView(_:doCommandBy:),并根据编辑器选择的行为识别insertNewline(_:)命令。 - 如果将该命令作为插入行处理,请保留当前文本、插入新行,并返回
true以处理该命令。对于委托不处理的命令,返回false。 - 将新的文本视图附加到其所属窗口后,通过该窗口的
makeFirstResponder(_:)方法请求焦点。
Apple 记录了换行命令以及委托的布尔值约定:true 表示委托已处理该命令,而 false 则允许文本视图尝试处理。这为应用自有的编辑器提供了明确的命令处理路径。Apple:insertNewline(_:),Apple:textView(_:doCommandBy:)。
添加自定义命令处理时,请保留正在进行的文本输入组合。AppKit 提供 hasMarkedText() 来报告已标记文本;决定委托是否应将命令留给文本系统处理时,请使用该状态。在生成的编辑器中测试输入组合,不要假设所有与 Return 相关的交互都应插入一行。Apple:hasMarkedText()。
对于焦点,Apple 明确指示调用方使用所属窗口的 makeFirstResponder(_:),而不是直接调用文本视图的 becomeFirstResponder()。TextKit 布局访问也有特定版本的影响:在 macOS 12 及更高版本中,显式访问文本视图的 layoutManager 会使其切换到使用 NSLayoutManager 的兼容模式。插入行本身不需要访问该属性。Apple:NSTextView。
对于单行编辑任务,请从完整的 SwiftUI 示例开始,并在 Mac 应用中验证插入、可见性和输入流程。稳定的身份、明确的插入位置和有针对性的焦点请求构成了核心行为;当编辑任务需要命令和选区控制时,再改用 AppKit 文本视图。
来源
- onSubmit(of:_:) — Apple
- FocusState — Apple
- TextField — Apple
- TextEditor — Apple
- ForEach — Apple
- onAppear(perform:) — Apple
- insert(_:at:) — Apple
- scrollTo(_:anchor:) — Apple
- submitLabel(_:) — Apple
- NSViewRepresentable — Apple
- insertNewline(_:) — Apple
- textView(_:doCommandBy:) — Apple
- hasMarkedText() — Apple
- NSTextView — Apple