当自定义 Mac 界面执行一个命名操作时,请使用 SwiftUI Button。如果交互需要手势信息(例如画布上的位置),请使用点击手势。本指南将构建一个小型形状编辑器,展示两者各自适用的场景。
Apple 明确建议:只要某个元素的作用是按钮,就应使用按钮。按钮的标签可以使用任意视图,因此自定义外观并不需要替换其交互行为。SwiftUI 提供标准按钮行为,包括辅助功能标签和提示。手势
界面需要哪种交互?
对于开发 Mac 应用的开发者,关键问题是交互必须传递哪些信息。标有 Add circle 的图块表达的是一个命令。点击空白绘图界面并在该处放置一个圆,则表达了一个命令以及一个位置。多出的输入会改变适合采用的实现方式。
下面的决策参考将 Apple 文档中区分的要点应用到一个普通的桌面形状编辑器中。这些是根据 API 得出的实现建议,并非适用于所有界面的规则。
| 读者想要的交互 | 起始方案 | 原因 |
|---|---|---|
点击标有 Add circle 的自定义图块 |
使用自定义标签的 Button |
该界面用于触发一个命名操作。 |
| 为该图块设置独特背景和按下时的外观 | ButtonStyle |
它可以自定义外观,同时保留标准按钮交互。 |
| 点击画布,在该位置放置一个圆 | SpatialTapGesture |
其结果包含交互位置。 |
| 在内容视图上识别两次点击,无需坐标 | onTapGesture(count: 2) |
该修饰符接受必需的点击次数。 |
| 在 AppKit 交互中配置特定鼠标按键 | NSClickGestureRecognizer |
它提供鼠标按键和点击次数配置。 |
Apple 将按钮描述为与标签配对的操作,并将通过 ButtonStyle 自定义外观与通过 PrimitiveButtonStyle 自定义交互行为区分开来。其手势 API 则分别记录了点击次数和空间位置结果。Button、onTapGesture(count:perform:)、SpatialTapGesture、NSClickGestureRecognizer
因此,自定义操作图块应从按钮开始。如果能明确界面所需的额外输入或识别要求,再使用手势。
如何自定义按钮而不重建其行为?
将命令放入按钮的操作闭包,将图形放入其标签。使用 ButtonStyle 来调整呈现方式。Apple 文档指出,该协议会保留平台的标准交互行为;PrimitiveButtonStyle 则是有意更改交互行为时使用的独立扩展点。ButtonStyle
下面的示例包含两个并列界面:一个自定义 Add circle 按钮和一个矩形放置画布。两者都会调用同一个修改函数。按钮会选择一个可预测的位置;画布会提供一个局部坐标点。这样,无需引入文件、外部内容或其他应用,就能直观展示两者的区别。
使用 SwiftUI 创建 macOS App 项目,并将部署目标设为 macOS 13 或更高版本,这是 SpatialTapGesture 文档规定的最低版本。将生成的 app 和 content view 定义替换为第一个文件,并将第二个文件添加到同一 target。确保只保留一个 @main app 声明。代码已经过检查,但尚未在 macOS SDK 环境中编译或运行。SpatialTapGesture
完整应用由以下两个文件组成。首先是 ShapeSurfaceApp.swift:
import SwiftUI
import Foundation
@main
struct ShapeSurfaceApp: App {
var body: some Scene {
WindowGroup {
ShapeSurfaceView()
}
}
}
private struct PlacedCircle: Identifiable {
let id = UUID()
let center: CGPoint
}
struct ShapeSurfaceView: View {
@State private var circles: [PlacedCircle] = []
private let limit = 6
private var isFull: Bool {
circles.count >= limit
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Button {
let x = 35 + CGFloat(circles.count) * 50
addCircle(at: CGPoint(x: x, y: 100))
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color.blue)
.frame(width: 24, height: 24)
Text("Add circle")
}
.frame(width: 280, height: 64)
.contentShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(SurfaceButtonStyle())
.keyboardShortcut("a", modifiers: [.command, .shift])
.disabled(isFull)
.opacity(isFull ? 0.4 : 1)
Text("Click the canvas to place a circle. Maximum: 6.")
ZStack(alignment: .topLeading) {
Color.gray.opacity(0.1)
ForEach(circles) { circle in
Circle()
.fill(Color.blue)
.frame(width: 24, height: 24)
.position(circle.center)
}
}
.frame(width: 320, height: 200)
.contentShape(Rectangle())
.gesture(
SpatialTapGesture()
.onEnded { value in
addCircle(at: value.location)
}
)
.disabled(isFull)
.border(Color.gray)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Circle canvas")
.accessibilityValue("\(circles.count) of \(limit) circles")
Text(isFull
? "Six circles placed. Clear to start again."
: "\(circles.count) of \(limit) circles placed")
Button("Clear circles") {
circles.removeAll()
}
.disabled(circles.isEmpty)
}
.padding(24)
}
private func addCircle(at point: CGPoint) {
guard !isFull else { return }
// Keep each 24-point circle inside the 320-by-200 canvas.
let center = CGPoint(
x: min(max(point.x, 12), 308),
y: min(max(point.y, 12), 188)
)
circles.append(PlacedCircle(center: center))
}
}
其次是 SurfaceButtonStyle.swift:
import SwiftUI
struct SurfaceButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.opacity(
configuration.isPressed ? 0.25 : 0.1
))
)
}
}
该样式读取 configuration.isPressed 来绘制反馈;它本身不会调用操作。Apple 将该属性定义为按钮当前的按下状态。这样,渲染和操作执行就分属不同位置。isPressed
六个圆的上限是示例中的应用规则。两种入口都使用 addCircle(at:),该函数会先检查容量,再追加圆。禁用状态的外观会传达这一限制,而共享的保护逻辑可确保修改规则一致。Apple 文档指出,disabled(_:) 用于控制视图的交互,包括点击和手势。disabled(_:)
为什么画布要使用空间点击手势?
画布需要知道用户点击的位置。基本的 onTapGesture(count:perform:) 重载会提供一个不带位置参数的操作。SpatialTapGesture 会明确报告位置,其初始化器默认在局部坐标空间中识别一次交互。onTapGesture(count:perform:)、SpatialTapGesture、init(count:coordinateSpace:)
在此程序中,手势附加到固定尺寸的画布上,圆的位置由画布的局部坐标决定。放置函数会将每个圆心限制在距离边缘 12 点的位置,这与圆的半径一致。因此,即使点击靠近边缘,也会将整个圆放在画布内。允许圆相互重叠;即使形状重叠,圆的数量仍会显示。
按钮和画布的键盘行为也有意设计得不同。按钮快捷键会执行与激活该按钮相同的操作,即在下一个位置添加一个圆。它不会生成指针位置。Apple 文档指出,键盘快捷键会调用控件的主要操作,并说明如果快捷键分配重复,会调用找到的第一个匹配控件。在示例窗口中只分配一次此快捷键。keyboardShortcut(_:modifiers:)
画布的辅助功能标签和数量描述的是其内容;它们并未提供用于任意坐标放置的键盘工作流。按钮则提供了无需指针即可在预定义位置添加圆的方式。如果键盘控件也必须支持精确放置,请添加明确的位置编辑界面,不要假定手势或描述性标签就能提供这种能力。
如何让命中测试与自定义界面相匹配?
确定标签的尺寸后,再定义预期的交互区域。在示例中,按钮标签的尺寸为 280×64,后接一个圆角矩形 contentShape。画布先设定为 320×200 的尺寸,再声明矩形内容形状。Apple 将 contentShape(_:eoFill:) 定义为用于命中测试的形状。contentShape(_:eoFill:)
这样一来,可见界面就有了明确的交互边界。如果重新设计时将 frame 移到按钮标签之外,请再次检查结果:示例中的尺寸设置和内容形状需要一起放在标签内部。应有意识地设置绘图、布局和交互几何范围,而不是通过添加另一个操作处理器来修复无响应的边缘区域。
如果界面不再响应,请按以下顺序检查:
- 检查几何范围。 点击标签文字、图形和空白内部区域。确认内容形状描述了预期的边界。
- 检查禁用的祖先视图。 禁用的容器会覆盖已启用的后代视图,因此局部的
.disabled(false)可能无法恢复交互。disabled(_:) - 检查装饰性覆盖层。 如果某个视图纯粹用于视觉呈现,可以通过
.allowsHitTesting(false)将其排除在命中测试之外。将其应用于装饰元素,而非交互界面。allowsHitTesting(_:) - 检查相互竞争的处理器。 更改手势优先级之前,先确认哪个视图拥有该操作。
如何避免处理器冲突和所有权错误?
为每条预期的激活路径使用一个处理器。在示例中,自定义按钮负责自己的命令,画布负责基于位置的放置。两者都没有嵌套在另一个放置手势的父视图中。这样的结构便于检查一次点击预期产生的效果。
不要仅仅为了调用同一个修改函数,就给按钮再附加第二个点击处理器。同样,除非你已确定父视图和子视图的交互是否应该同时发生,否则不要在包含多个控件的父视图上添加手势。Apple 的 simultaneousGesture 示例展示了父级和子级处理器可以同时运行。highPriorityGesture 则会让附加的手势拥有更高优先级。这些修饰符表达的是交互策略,不能互相替代来修补问题。simultaneousGesture(_:including:)、highPriorityGesture(_:including:)
让应用状态归属于拥有它的视图。这里,ShapeSurfaceView 存储模拟的圆,而样式只负责渲染传入的标签。Apple 建议将私有状态放在所有需要访问它的视图中层级最高的位置,并视情况将值或绑定传递给后代视图。State
如果之后添加的 AppKit 适配器存储了回调,请单独检查其捕获关系。Swift 闭包可以捕获周围的值;如果某个类存储了一个会捕获该类实例自身的闭包,就可能形成强引用循环。应根据实际对象生命周期选择捕获列表。将按钮改为手势并不能解决回调所有权问题。闭包
什么时候适合采用 AppKit?
当你自己的界面需要其文档中说明的鼠标专属配置时,可以考虑使用 AppKit。NSClickGestureRecognizer 支持设置必需的点击次数和鼠标按键掩码。Apple 指出,第 0 位表示主按键,第 1 位表示次按键;识别成功需要完成配置的点击次数,且期间不能拖动。它还会自动为配置的鼠标按键启用事件延迟。NSClickGestureRecognizer、buttonMask
根据部署目标和你拥有的组件,选择集成方式:
- macOS 26 或更高版本:
NSGestureRecognizerRepresentable可通过.gesture(...)将 AppKit 识别器直接集成到 SwiftUI 层级中。SwiftUI 会安装操作目标;请实现该可表示对象的操作处理器,并使用其 context 转换器进行坐标转换。Apple 提醒,识别器关联视图的几何范围可能与 SwiftUI 视图不同。NSGestureRecognizerRepresentable - 在 SwiftUI 中托管 AppKit 视图:
NSViewRepresentable会管理视图的创建、更新和拆除。使用其 coordinator 处理回调,并让 SwiftUI 控制被表示视图的布局。此 API 从 macOS 10.15 起可用。NSViewRepresentable
添加任一适配器时,都要遵守事件边界。AppKit 文档指出,识别器作用于其附加的视图及其后代视图,会先于视图接收相关事件,也可能延迟这些事件的传递。文档还明确指出,它不支持将事件跟踪交给拖放或弹出式菜单等其他机制。应通过这些交互各自支持的机制来设计,而不要把点击识别器当作通用事件中继。NSGestureRecognizer
如何验证最终的 Mac 交互?
在 Xcode 中构建示例,并在将其应用到更大的视图层级前检查可观察到的行为:
- 点击自定义按钮的文字和空白内部区域。每次激活都应添加一个圆,并将计数更新一次。
- 在活动窗口中按下
Command–Shift–A。它应执行按钮按位置添加圆的操作。 - 点击画布上的不同位置,包括靠近边缘的位置。圆应根据局部坐标定位,并保留代码中记录的边距。
- 添加到六个圆。两种放置路径都应停止添加,但
Clear circles仍可用。 - 清空画布。之后应可以再次放置圆。
- 使用 VoiceOver 检查按钮,并在 Mac 当前设置下测试键盘导航。确认应用样式后,其操作仍然清晰易懂。
对于自定义 Mac 操作界面,应从交互的含义出发:命名命令应放入 Button,视觉自定义应放在其标签和样式中,而依赖位置的放置应由画布手势处理。通过同一个状态修改函数连接这些路径,只有当 AppKit 的额外配置能满足具体需求时,才引入 AppKit。
来源
Button — Apple 开发者文档。
手势 — Apple 开发者文档。
ButtonStyle — Apple 开发者文档。
isPressed — Apple 开发者文档。
onTapGesture(count:perform:) — Apple 开发者文档。
SpatialTapGesture — Apple 开发者文档。
init(count:coordinateSpace:) — Apple 开发者文档。
contentShape(_:eoFill:) — Apple 开发者文档。
disabled(_:) — Apple 开发者文档。
allowsHitTesting(_:) — Apple 开发者文档。
keyboardShortcut(_:modifiers:) — Apple 开发者文档。
simultaneousGesture(_:including:) — Apple 开发者文档。
highPriorityGesture(_:including:) — Apple 开发者文档。
状态 — Apple 开发者文档。
闭包 — Swift.org,《Swift 编程语言》。
NSClickGestureRecognizer — Apple 开发者文档。
buttonMask — Apple 开发者文档。
NSGestureRecognizer — Apple 开发者文档。
NSGestureRecognizerRepresentable — Apple 开发者文档。
NSViewRepresentable — Apple 开发者文档。