要将 SwiftUI 菜单命令发送到活动的 NSTextView,请在命令运行时解析键窗口的第一响应者,然后将操作发送到该视图。本教程将构建一个包含两个编辑器的示例,其中 Select First Line 命令只影响当前聚焦的编辑器。
此任务是在你自己的 macOS 应用中创建自定义菜单操作。示例使用合成文本、应用自有的 NSTextView 子类,以及公开的 SwiftUI 和 AppKit API。关键设计决策在于:命令应沿用 AppKit 的响应者链,还是只能作用于某个特定的活动编辑器。
明确“活动文本视图”的含义
在此示例中,活动编辑器是当前作为键窗口第一响应者的 SampleTextView 实例。Apple 将键窗口定义为接收键盘事件的窗口;其 firstResponder 标识通常最先接收事件和操作的响应者。在调用命令时读取这些属性,可使命令指向当前目标。Apple 的 keyWindow 文档、Apple 的 firstResponder 文档。
AppKit 也支持不指定目标的操作:将 nil 作为目标传给 NSApplication.sendAction(_:to:from:),应用便会查找实现该选择器的响应者。搜索从键窗口的第一响应者开始,但也可能继续经过代理和主窗口,最后到达应用级处理程序。因此,不指定目标并不意味着“只在此键窗口的活动文本视图上执行”。Apple 的操作路由文档。
| 所需行为 | 路由方式 | 结果 |
|---|---|---|
| 让 AppKit 查找符合条件的处理程序 | 使用 to: nil 发送操作 |
按照常规响应者链进行回退。 |
| 只影响应用自有的活动编辑器 | 解析并检查第一响应者的类型,然后将其显式设为目标 | 如果该编辑器不是活动编辑器,命令就会停止。 |
| 无论焦点在哪里,都操作某个特定编辑器 | 将该编辑器指定为目标 | 这与面向活动编辑器的命令属于不同的交互方式。 |
下面的实现选择第二种行为。检查自定义子类也可以避免无关文本字段的编辑器仅仅因为它是文本视图就成为目标。
构建完整的 SwiftUI 和 AppKit 示例
创建一个使用 SwiftUI 的 macOS 应用项目;此示例要求部署目标为 macOS 13 或更高版本。将生成的应用入口替换为以下单个 Swift 文件,并删除其他任何 @main 声明。程序包含两个编辑器、它们的初始文本、菜单和操作处理程序。
NSViewRepresentable 是 SwiftUI 提供的、用于嵌入 AppKit 视图的桥接方式。场景命令提供菜单内容,而 CommandMenu 会在 macOS 菜单栏中创建应用专属菜单。Apple 的 NSViewRepresentable 文档、场景命令、命令菜单。
import SwiftUI
import AppKit
import Foundation
@main
@MainActor
struct RoutingDemoApp: App {
var body: some Scene {
WindowGroup("Routing Demo") {
ContentView()
}
.commands {
CommandMenu("Sample Actions") {
Button("Select First Line") {
Self.routeSelectFirstLine()
}
}
}
}
private static func routeSelectFirstLine() {
let application = NSApplication.shared
guard let editor = application.keyWindow?.firstResponder
as? SampleTextView else {
return
}
_ = application.sendAction(
#selector(SampleTextView.selectFirstSampleLine(_:)),
to: editor,
from: nil
)
}
}
@MainActor
struct ContentView: View {
@State private var windowLabel = "Sample Window"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Window label", text: $windowLabel)
HStack(spacing: 12) {
SampleEditor(initialText: "Sample Alpha\nAlpha details")
SampleEditor(initialText: "Sample Beta\nBeta details")
}
Text("Click an editor, then use Sample Actions.")
.font(.caption)
}
.padding()
.frame(minWidth: 640, minHeight: 320)
}
}
@MainActor
final class SampleTextView: NSTextView {
@objc func selectFirstSampleLine(_ sender: Any?) {
guard isSelectable, !hasMarkedText() else { return }
let contents = string as NSString
guard contents.length > 0 else { return }
let firstLine = contents.lineRange(
for: NSRange(location: 0, length: 0)
)
setSelectedRange(firstLine)
scrollRangeToVisible(firstLine)
}
}
@MainActor
struct SampleEditor: NSViewRepresentable {
let initialText: String
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.borderType = .bezelBorder
let editor = SampleTextView(
frame: NSRect(x: 0, y: 0, width: 300, height: 240)
)
editor.isEditable = true
editor.isSelectable = true
editor.isRichText = false
editor.allowsUndo = true
editor.font = NSFont.monospacedSystemFont(
ofSize: 14, weight: .regular
)
editor.minSize = .zero
editor.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
editor.isVerticallyResizable = true
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainer?.containerSize = NSSize(
width: 300,
height: CGFloat.greatestFiniteMagnitude
)
editor.textContainer?.widthTracksTextView = true
editor.string = initialText
scrollView.documentView = editor
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
// AppKit owns the editable contents in this example.
}
}
这是一个完整的应用示例,编辑器内容保存在内存中。已根据检索到的 API 文档对其进行检查;由于当前环境为 Linux 且没有 Swift 工具链,因此未在此处编译或运行。
选择器为何会到达预期的编辑器
路由函数会在分发操作之前立即获取第一响应者。它只接受 SampleTextView,并将该对象本身设为操作目标。因此,菜单不会保留之前点击的编辑器;如果类型检查失败,也不会继续搜索其他窗口。
操作方法使用 @objc,因为目标-操作分发使用 Objective-C 选择器。#selector 表达式引用已声明的方法,而不是手写的选择器字符串。Apple 介绍了如何通过 Objective-C 目标-操作 API 调用 Swift 方法时使用这种组合。在 Swift 中使用 Objective-C 运行时功能。
sendAction 的返回值表示消息是否已发送,并不表示处理程序是否更改了选择内容:处理程序可能已收到消息,但因内容为空或存在标记文本而提前返回。此示例有意丢弃分发结果,因为目标和方法已经确定。Apple 关于 sendAction 返回值的文档。
选择命令实际选择的内容
处理程序会调用 NSString.lineRange(for:),获取包含字符串起始位置的逻辑行。该范围在存在行终止符时也会包含它。因此,即使这一行在显示时换成了多行,该操作仍会选中第一行,以换行符分隔。Apple 的行范围文档。
对于提供的文本,左侧编辑器会选中 Sample Alpha 及其后的换行符;右侧编辑器会选中 Sample Beta 及其后的换行符。该命令会替换之前的选择内容,但不会更改文本。
setSelectedRange(_:) 会应用选择内容,而 scrollRangeToVisible(_:) 会将其起始字符滚动到视野中。空内容检查可避免在没有文本时尝试执行选择操作。检查标记文本是此示例明确采用的策略:输入组合进行时,暂缓执行此自定义选择操作。选择范围、将范围滚动到视野中、标记文本。
分开处理视图创建、编辑和焦点
这个可表示对象会创建一个 NSScrollView,并将自定义文本视图设为其文档视图。编辑器可垂直扩展,而其文本容器会随宽度调整。这符合 Apple 文档所述的滚动文本视图布局方式。该布局方式见于 Apple 的归档版 AppKit 指南;此处引用的 SwiftUI 桥接和操作 API 使用的是当前文档。在 NSScrollView 中放置 NSTextView 对象。
SwiftUI 控制可表示滚动视图的布局。代码会设置嵌套文档视图的初始几何尺寸和 AppKit 调整大小行为,但不会反复指定可表示滚动视图的框架。Apple 特别提醒,不要更改 SwiftUI 为可表示对象管理的布局属性。Apple 关于可表示对象布局的指导。
示例只在创建期间设置 initialText。其空的更新方法有意将后续输入留给 AppKit 处理。如果应用需要 SwiftUI 文档模型,请添加协调器和基于代理的同步,并明确规定编辑期间模型变更到达时的处理策略。每次更新都重新设置初始字符串,会替换用户当前的内容。Apple 将协调器描述为把 AppKit 交互转发到 SwiftUI 的桥梁。Apple 关于可表示对象协调的指导。
在本演示中,点击编辑器即可设置焦点。如果应用需要以编程方式移动焦点,请在视图属于某个窗口后,使用该窗口的 makeFirstResponder(_:)。Apple 明确指出,不要直接调用文本视图的 becomeFirstResponder()。避免在每次 SwiftUI 更新期间请求焦点,否则会不断改变用户的编辑目标。Apple 关于文本视图焦点的指导。
保留现有的 TextKit 配置
菜单路由不需要更改文本布局引擎。此示例使用 NSTextView(frame:);Apple 文档说明,该初始化方法默认使用 NSLayoutManager。从 macOS 12 开始,也支持显式使用 NSTextLayoutManager 构造对象。Apple 的文本视图初始化文档。
对于现有的 TextKit 2 编辑器,请保留其构造方式,并将操作添加到其子类中。不要仅为定位或路由到编辑器而读取 layoutManager:Apple 文档指出,在 TextKit 2 文本视图上访问该属性可能会使其切换到兼容模式。路由代码只需要响应者和操作方法。Apple 关于 NSTextView 兼容模式的文档。
验证路由并处理不可用的命令
在 macOS 上按以下步骤检查预期行为。这些是根据实现推导出的预期结果,并非实际运行结果。
- 点击左侧编辑器,然后选择
Sample Actions→Select First Line。左侧编辑器的第一逻辑行应被选中。 - 点击右侧编辑器并调用同一命令。只有右侧编辑器的选择内容应发生变化。
- 点击
Window label并调用命令。两个自定义编辑器都应保持不变,因为第一响应者未通过子类检查。 - 删除某个编辑器的全部内容,然后在该编辑器中调用命令。空内容保护条件应直接返回,且不更改文本。
- 添加多行文本,向下滚动,然后调用命令。第一行应被选中并滚动到视野中。
在此最简示例中,即使焦点位于其他位置,该命令仍会保持启用状态。调用时的检查会决定它是否执行。仅在 SwiftUI 的 .disabled 表达式中读取 keyWindow?.firstResponder,并不能建立一种机制,使该表达式在 AppKit 焦点变化时自动更新。
对于更大型的 SwiftUI 界面,可通过关注焦点的状态发布适当的命令能力,使用 @FocusedValue 观察该值,并根据该状态推导按钮的 .disabled 条件。AppKit 桥接必须确保此能力与实际编辑器焦点保持一致。同时保留调用时的检查。Apple 文档说明,FocusedValue 用于观察来自当前聚焦视图或其祖先的值。聚焦值、禁用交互。
如果改用 AppKit 菜单项并将其目标设为编辑器,validateMenuItem(_:) 可提供 AppKit 的验证钩子。仅在此子类中添加该方法,并不能验证 SwiftUI 按钮:按钮的操作是一个闭包,之后会再分发另一个操作。Apple 要求验证对象必须是菜单项的目标。Apple 的菜单项验证文档。
添加更多命令之前,先确定目标策略。如果所需行为包含响应者链回退,请使用不指定目标的操作;如果操作只应属于活动编辑器,则解析并指定第一响应者为目标。用户在文本视图和其他控件之间切换时,这一选择能让同一个菜单保持可预测。
来源
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu— Apple- 在 Swift 中使用 Objective-C 运行时功能 — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- 在 NSScrollView 中放置 NSTextView 对象 — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple