使用 DragGesture、RotateGesture 與 MagnifyGesture 搭配 simultaneously(with:),再透過單一轉換流程呈現其值。對於 Mac 畫布,讓輸入表面保持固定,並將每個手勢的暫時變化與已提交結果分開儲存。

本教學會在你自己的 macOS App 中建立一個小型圖形檢視器:按住主要滑鼠按鈕拖曳以平移、捏合以縮放,並使用觸控式軌跡板旋轉手勢轉動圖形。它只使用記憶體中的矩形和圓形。這種組合允許已辨識的手勢重疊;實際會重疊哪些輸入,取決於裝置傳送的事件。

在 Mac 上「同時」代表什麼?

Apple 的 simultaneously(with:) 會將兩個手勢組合成另一個手勢,因此結果還能再與第三個手勢組合。這提供了平移、旋轉和縮放所需的組合方式,無須長按或其他前置動作。Apple 的同時手勢文件說明了這種組合方式。

撰寫程式碼前,先決定平移的意義。這裡指按住滑鼠主要按鈕拖曳,包括在觸控式軌跡板上按下並拖曳。兩指在觸控式軌跡板上滑動通常會產生捲動輸入。Apple 封存的事件指南明確區分了這種捲動事件流與放大、旋轉手勢;目前的 AppKit 文件仍透過 scrollWheel(with:) 提供捲動功能。加入拖曳手勢不會把捲動轉成拖曳。處理觸控式軌跡板事件、scrollWheel(with:)

使用此決策表選擇畫布的輸入層:

需要的互動方式 起點 需保留的界線
按住並拖曳以平移、捏合縮放、觸控式軌跡板旋轉 以下的 SwiftUI 範例 組合允許已辨識的手勢事件流彼此重疊。
兩指捲動以平移 嵌入 SwiftUI 的 AppKit 畫布 捲動仍是捲動事件流。
由單一手指動作模式保證產生三個轉換分量 另行指定的自訂互動 僅靠同時組合,無法確保硬體或 macOS 會傳送每個分量。

此範例需要 macOS 14 或更新版本,因為該版本開始提供 MagnifyGesture 和 RotateGesture。它使用基本初始化器,而非較新的輸入種類設定 API。MagnifyGesture、RotateGesture

如何組織轉換狀態?

為每個分量提供一個已提交值和一個即時手勢值。平移和旋轉以加法組合;縮放則以乘法組合。以下方程式定義此範例的行為,並非 SwiftUI 強制要求的規則:

顯示的平移 = 已提交平移 + 即時拖曳平移
顯示的旋轉 = 已提交旋轉 + 即時旋轉
顯示的縮放 = clamp(已提交縮放 × 即時放大倍率)

即時值使用 @GestureState。Apple 文件指出,手勢變為非作用中時,此狀態會重設。透過每個手勢各自的 onEnded 回呼提交分量;只有手勢成功完成時才會呼叫該回呼。這樣取消的意義就很明確:捨棄該分量尚未完成的預覽,保留先前已提交的值。GestureState、onEnded(_:)

在組合手勢前,先將這些回呼附加到個別手勢上。如此一來,每個回呼都會收到各自型別的值,且只會變更自己負責的屬性。較晚開始的手勢不會覆寫共用的起始轉換快照。

拖曳平移值需要特別留意:它代表從手勢開始以來的總移動距離。若每次更新都將這個總量加到已提交偏移量,就會重複計算先前的移動距離。將總量指定給即時預覽,完成時再將最後的總量加一次。DragGesture.Value.translation

建立完整的 SwiftUI 範例

建立一個部署目標為 macOS 14 或更新版本的 macOS SwiftUI App。將產生的 App 入口和內容檢視替換為以下單一檔案;目標中只保留一個 @main 宣告。無須資產、套件或外部資料。

灰色畫布負責接收手勢。其子圖形套用視覺轉換,因此平移或旋轉圖形不會轉換手勢的輸入表面。此範例刻意以圖形中心作為縮放和旋轉的基準點。

import SwiftUI

@main
struct TransformCanvasApp: App {
    var body: some Scene {
        WindowGroup {
            TransformCanvas()
                .frame(minWidth: 400, minHeight: 300)
        }
        .defaultSize(width: 720, height: 500)
    }
}

struct TransformCanvas: View {
    @State private var offset = CGSize.zero
    @State private var angle = Angle.zero
    @State private var scale: CGFloat = 1

    @GestureState private var liveOffset = CGSize.zero
    @GestureState private var liveAngle = Angle.zero
    @GestureState private var liveScale: CGFloat = 1

    private func boundedScale(_ value: CGFloat) -> CGFloat {
        min(max(value, 0.25), 4)
    }

    private var pan: some Gesture {
        DragGesture(minimumDistance: 3, coordinateSpace: .local)
            .updating($liveOffset) { value, state, _ in
                state = value.translation
            }
            .onEnded { value in
                offset = CGSize(
                    width: offset.width + value.translation.width,
                    height: offset.height + value.translation.height
                )
            }
    }

    private var rotation: some Gesture {
        RotateGesture()
            .updating($liveAngle) { value, state, _ in
                state = value.rotation
            }
            .onEnded { value in
                angle = angle + value.rotation
            }
    }

    private var zoom: some Gesture {
        MagnifyGesture()
            .updating($liveScale) { value, state, _ in
                state = value.magnification
            }
            .onEnded { value in
                scale = boundedScale(scale * value.magnification)
            }
    }

    private var manipulation: some Gesture {
        pan.simultaneously(with: rotation)
            .simultaneously(with: zoom)
    }

    var body: some View {
        ZStack {
            Color.gray.opacity(0.12)

            RoundedRectangle(cornerRadius: 18)
                .fill(Color.blue)
                .overlay(alignment: .topLeading) {
                    Circle()
                        .fill(Color.white)
                        .frame(width: 24, height: 24)
                        .padding(16)
                }
                .frame(width: 180, height: 120)
                .scaleEffect(
                    boundedScale(scale * liveScale),
                    anchor: .center
                )
                .rotationEffect(angle + liveAngle, anchor: .center)
                .offset(CGSize(
                    width: offset.width + liveOffset.width,
                    height: offset.height + liveOffset.height
                ))
                .allowsHitTesting(false)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .clipped()
        .gesture(manipulation)
    }
}

Apple 文件說明了底層的拖曳、放大和旋轉手勢類型,以及如何將手勢附加到檢視。此範例將這些 API 與明確的狀態和座標策略結合。研究環境無法使用 SwiftUI,因此本文只依文件檢視了這段程式,未在此處編譯或執行。DragGesture、MagnifyGesture、RotateGesture

為什麼先縮放、再旋轉、最後偏移?

預期的對應方式是以圖形中心為基準縮放,再以同一中心旋轉,最後在畫布座標中平移。最後套用偏移,可讓平移不受圖形的縮放和旋轉影響。即使圖形已轉向,將指標水平移動也應使圖形水平移動。

此順序是根據範例的轉換流程所做的設計選擇。Apple 文件指出,scaleEffect 會以指定錨點縮放繪製結果,rotationEffect 會旋轉繪製結果,而 offset 會移動顯示內容。這些視覺效果不會讓固定的畫布變成經過轉換的版面配置容器。scaleEffect(_:anchor:)、rotationEffect(_:anchor:)、offset(_:)

0.25 和 4 的上下限是示範用的 App 設定。預覽和提交時都會套用同一個限制,因此放開手勢後顯示的縮放比例會維持不變。Swift 的 min 和 max 會選出用來表示這些界限的較小值和較大值。min(::)、max(::)

依照具體預期檢查互動

先分別測試各個分量,再使用可用的輸入裝置嘗試重疊操作。白色圓形即使在矩形很小時,也能讓旋轉效果清楚可見。

  1. 在畫布上按住並拖曳,放開後再拖曳一次。第二次拖曳應從第一次拖曳已提交的位置開始。
  2. 捏合放大圖形,放開後再捏合一次。每次手勢都應將保留的縮放比例相乘。
  3. 旋轉後放開,再旋轉一次。角度應累加。
  4. 放大並旋轉後,水平拖曳。圖形仍應沿畫布的水平軸平移。
  5. 若裝置傳送重疊輸入,請在繼續操作另一個分量時結束其中一個分量。其餘分量應繼續更新自己的屬性。
  6. 中斷作用中的手勢,例如切換 App。若手勢未成功完成便變為非作用中,其即時預覽應消失,而非讓暫時狀態卡住。

以示範性算術檢查為例,假設保留的縮放比例為 1.5,而捏合回報 1.2,顯示結果就是 1.8。若保留的角度為 30 度,即時旋轉為 15 度,顯示角度就是 45 度。無論縮放和角度為何,40 點的拖曳平移仍會是 40 點的畫布偏移。

此範例會獨立提交各個分量。若旋轉成功完成,而仍作用中的拖曳之後遭到取消,已完成的旋轉會保留,未完成的拖曳則會復原。若編輯器需要所有分量作為單一操作原子性提交,就必須另外建立互動工作階段模型,並明確定義提交和復原策略。

由於平移刻意不設限,圖形也可以被拖到可見畫布之外。畫布仍可拖曳,因此能往相反方向移回。完成版的檢視器可以加入獨立的重設指令,或依自身內容邊界限制平移。

何時應改用 AppKit 畫布?

若任務明確要求兩指捲動平移或直接控制捲動慣性,請選擇 AppKit 輸入層。透過 NSViewRepresentable 嵌入自有的 AppKit 檢視;Apple 提供此介面,可在 SwiftUI 中建立、更新和拆除 AppKit 檢視。NSViewRepresentable

每個輸入事件流只指定一個負責元件。在該 AppKit 畫布中,透過 scrollWheel(with:) 處理捲動,並使用辨識器處理放大和旋轉。將產生的變化傳回相同的平移、角度和縮放模型。避免再附加另一個 SwiftUI 處理程式,將相同輸入套用到該模型。

若 AppKit 辨識器需要並行運作,請實作 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:),並僅針對畫布中預定的辨識器配對回傳 true。Apple 文件指出,此委派方法允許同時辨識;它無法憑空產生缺少的輸入事件流。同時辨識委派方法

在橋接時保留各值的慣例。AppKit 的放大辨識器會回報一個數值,必須加上 1 才能取得縮放倍率;其旋轉辨識器則提供弧度。不要原封不動地複製 SwiftUI 的值指定方式。NSMagnificationGestureRecognizer.magnification、NSRotationGestureRecognizer.rotation

對於捲動,Apple 指出 scrollingDeltaX 是偏好的水平差值屬性,並提醒非精確差值可能需要調整。其慣性文件說明,實體手勢停止後,捲動事件仍可能持續傳送。請決定畫布是否接受這段延續輸入,並確保每個接受的事件只路由一次。scrollingDeltaX、momentumPhase

橋接層應變更畫布的內容轉換,而 SwiftUI 仍負責管理所呈現檢視的版面配置。Apple 明確警告,不要直接設定所呈現檢視的 frame 或 bounds。使用 representable 的 coordinator 將委派和 target-action 的變更傳回 SwiftUI。NSViewRepresentable

對於按住並拖曳的檢視器,SwiftUI 實作提供了完整的起點:一個固定的輸入表面、三個各自管理的手勢分量,以及明確的轉換順序。擴充時請保留這些界線;若使用者需要透過捲動來平移,則選擇 AppKit 方案。

來源