当自定义 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 移到按钮标签之外,请再次检查结果:示例中的尺寸设置和内容形状需要一起放在标签内部。应有意识地设置绘图、布局和交互几何范围,而不是通过添加另一个操作处理器来修复无响应的边缘区域。

如果界面不再响应,请按以下顺序检查:

  1. 检查几何范围。 点击标签文字、图形和空白内部区域。确认内容形状描述了预期的边界。
  2. 检查禁用的祖先视图。 禁用的容器会覆盖已启用的后代视图,因此局部的 .disabled(false) 可能无法恢复交互。disabled(_:)
  3. 检查装饰性覆盖层。 如果某个视图纯粹用于视觉呈现,可以通过 .allowsHitTesting(false) 将其排除在命中测试之外。将其应用于装饰元素,而非交互界面。allowsHitTesting(_:)
  4. 检查相互竞争的处理器。 更改手势优先级之前,先确认哪个视图拥有该操作。

如何避免处理器冲突和所有权错误?

为每条预期的激活路径使用一个处理器。在示例中,自定义按钮负责自己的命令,画布负责基于位置的放置。两者都没有嵌套在另一个放置手势的父视图中。这样的结构便于检查一次点击预期产生的效果。

不要仅仅为了调用同一个修改函数,就给按钮再附加第二个点击处理器。同样,除非你已确定父视图和子视图的交互是否应该同时发生,否则不要在包含多个控件的父视图上添加手势。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。

来源