自訂 Mac 介面執行具名動作時,請使用 SwiftUI Button。若互動需要手勢資訊,例如畫布上的位置,請使用點擊手勢。本指南會建立一個小型圖形編輯器,示範兩者各自適用的情境。

Apple 明確建議,只要元素的功能如同按鈕,就應使用按鈕。按鈕可以使用任何檢視作為標籤,因此自訂外觀不必以替換互動行為為代價。SwiftUI 提供標準按鈕行為,包括輔助使用標籤與提示。手勢

介面需要哪種互動?

對於開發 Mac App 的開發者來說,關鍵問題是互動必須提供哪些資訊。標示為 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 按鈕,以及矩形放置畫布。兩者都會呼叫同一個狀態變更函式。按鈕會選擇可預期的插槽;畫布則提供區域座標點。如此一來,不必加入檔案、外部內容或另一個 App,就能清楚看出兩者的差別。

使用 SwiftUI 建立 macOS App 專案,並將部署目標設為 macOS 13 或更新版本,這是 SpatialTapGesture 文件記載的最低版本。將產生的 App 與內容檢視定義替換為第一個檔案,並將第二個檔案加入相同目標。保留且只保留一個 @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 x 64,後接一個圓角矩形 contentShape。畫布先設定 320 x 200 的框架,再宣告矩形內容形狀。Apple 將 contentShape(_:eoFill:) 定義為點擊測試所使用的形狀。contentShape(_:eoFill:)

如此可為可見介面明確設定互動邊界。若重新設計時將框架移到按鈕標籤之外,請再次檢查結果:範例中的尺寸設定與內容形狀必須一併留在標籤內。請明確規劃繪圖、版面配置與互動幾何,不要靠新增動作處理常式來補救沒有反應的邊界區域。

介面停止回應時,請依序檢查以下條件:

  1. 檢查幾何範圍。 點擊標籤的文字、圖像與空白內部區域。確認內容形狀涵蓋預期邊界。
  2. 檢查已停用的上層檢視。 已停用的容器會覆蓋已啟用的子檢視,因此區域性的 .disabled(false) 可能無法恢復互動。disabled(_:)
  3. 檢查裝飾性覆疊。 如果某個檢視純粹用於視覺裝飾,可透過 .allowsHitTesting(false) 將其排除於點擊測試之外。將此設定套用於裝飾元素,而非互動介面。allowsHitTesting(_:)
  4. 檢查競爭中的處理常式。 在變更手勢優先順序之前,先確認哪個檢視擁有此動作。

如何避免處理常式互相競爭及所有權錯誤?

每種預期的啟用方式只使用一個處理常式。在範例中,自訂按鈕負責其命令,畫布則負責依位置放置。兩者都不會放在另一個也附有放置手勢的父檢視中。這種結構讓人容易檢查單次點擊的預期效果。

不要為了呼叫相同的狀態變更函式,而在按鈕上再附加第二個點擊處理常式。同樣地,在多個控制項周圍加上父層手勢前,應先決定父層與子層互動是否都應執行。Apple 的 simultaneousGesture 範例展示了父層與子層的處理常式可以同時執行。highPriorityGesture 則會讓附加的手勢取得較高優先順序。這些修飾器表達的是互動政策,不能互相替代來修補問題。simultaneousGesture(_:including:)、highPriorityGesture(_:including:)

將應用程式狀態放在負責管理它的檢視中。此處由 ShapeSurfaceView 儲存範例圓形,樣式則只負責繪製傳入的標籤。Apple 建議在需要存取該狀態的最高層檢視中使用私有狀態,並視需要將值或繫結傳給子檢視。State

若後續的 AppKit 介接器儲存了回呼,請另外檢查其擷取關係。Swift 閉包可以擷取周圍的值;若某個類別儲存的閉包擷取了同一個實例,可能會形成強參照循環。請依實際物件生命週期選擇擷取清單。將按鈕改成手勢並不能解決回呼的所有權問題。Closures

何時該改用 AppKit?

當你自己的介面需要文件記載的滑鼠專屬設定時,可考慮使用 AppKit。NSClickGestureRecognizer 支援設定必要點擊次數與滑鼠按鈕遮罩。Apple 指定位元 0 代表主要按鈕,位元 1 代表次要按鈕;辨識時必須達到設定的點擊次數,且不可拖曳。它也會自動為已設定的滑鼠按鈕啟用事件延遲。NSClickGestureRecognizer、buttonMask

請依照部署目標與你負責的元件選擇介接方式:

  • macOS 26 或更新版本:NSGestureRecognizerRepresentable 可透過 .gesture(...),將 AppKit 辨識器直接整合至 SwiftUI 階層。SwiftUI 會安裝動作目標;請實作此可表示型別的動作處理常式,並使用其內容轉換器進行座標轉換。Apple 提醒,辨識器所關聯的檢視,其幾何範圍可能與 SwiftUI 檢視不同。NSGestureRecognizerRepresentable
  • 在 SwiftUI 中託管 AppKit 檢視:NSViewRepresentable 負責建立、更新與拆除你的檢視。使用其協調器處理回呼,並讓 SwiftUI 控制所表示檢視的版面配置。此 API 自 macOS 10.15 起提供。NSViewRepresentable

加入任一介接器時,請保留事件邊界。AppKit 文件指出,辨識器會在所附加的檢視及其子檢視上運作,在檢視之前接收相關事件,並可能延遲事件傳遞。文件也明確指出,它不支援將事件追蹤交給拖放或彈出式選單等其他機制處理。請透過支援的機制設計這些互動,不要把點擊辨識器當作通用事件轉送器。NSGestureRecognizer

完成 Mac 互動後應確認哪些項目?

在 Xcode 中建置範例,並在將其套用至更大的檢視階層前確認可觀察到的行為:

  • 點擊自訂按鈕的文字與空白內部區域。每次啟用都應新增一個圓形,且數量只更新一次。
  • 在作用中的視窗按下 Command–Shift–A。它應執行按鈕以插槽為基礎的動作。
  • 點擊畫布的不同位置,包括邊緣附近。圓形應依據區域座標放置,並保留程式碼中註明的邊界。
  • 放置六個圓形。兩種放置方式都應停止新增,而 Clear circles 仍可使用。
  • 清除畫布後,應可再次放置圓形。
  • 使用 VoiceOver 檢查按鈕,並依照 Mac 目前的設定測試鍵盤導覽。確認套用樣式後,按鈕的動作依然清楚易懂。

對於自訂 Mac 動作介面,請從互動意義著手:具名命令應放在 Button 中,視覺自訂應放在標籤與樣式中,而依位置放置應交由畫布手勢處理。讓這些操作路徑共用一個變更狀態的函式;只有在 AppKit 的額外設定能滿足具體需求時,才引入 AppKit。

來源