在 Mac App 中拖曳形狀時,請在手勢期間直接更新它的偏移量,然後在放開時以彈簧動畫效果呈現相同的偏移量。在 macOS 14 和後續版本中,SwiftUI 可以自動將追蹤到的拖曳速度帶入彈簧動畫。

本教學將建立一個簡單的桌面互動:朝任意方向拖曳藍色合成方塊,放開後讓它以彈簧動畫回到起始位置。所有狀態都保留在記憶體中,而手勢會附加在 App 自有視窗中的方塊上。

彈簧動畫如何承接拖曳結束時的速度?

當可動畫屬性變更時,SwiftUI 的 Transaction.tracksVelocity 會記錄速度。Apple 文件指出,手勢的 onChanged 和 updating 回呼會自動啟用這項追蹤。互動結束時,動畫可以使用累積的速度。因此,這種一般的 SwiftUI 互動不需要計算並傳入初始速度。Apple 的速度追蹤文件 明確說明了這個交接方式。

關鍵在於動畫屬性。在拖曳期間,將手勢的位移寫入方塊的偏移量。放開時,在 withAnimation 中將同一個偏移量改為目的位置。讓檢視及其狀態持續存在,SwiftUI 才能連續追蹤這個屬性。

此實作會讓拖曳更新不帶動畫。Apple 說明,交易的速度追蹤與自行管理速度的動畫無法同時使用。拖曳期間使用互動式彈簧也是可行的方法,但會改變方塊跟隨指標的方式;此處的直接跟隨互動不需要這種做法。交易速度追蹤

彈簧的目的位置與初始運動各有不同作用。目的位置指定方塊應停在哪裡;繼承的速度則代表放開時方塊的運動狀態。若方塊離開原位時仍在向外移動,放開後它可能會先繼續向外,再由彈簧帶回原位。Apple 在 使用彈簧製作動畫 中示範了彈簧如何延續手勢速度所帶來的運動。

建立完整的 macOS 拖曳與放開範例

在 Xcode 中使用 SwiftUI 建立 macOS App,將部署目標設為 macOS 14.0 或後續版本,並以以下程式碼取代產生的 App 進入點檔案。此目標中只保留一個 @main App 宣告。本範例使用 macOS 14 的動畫完成 API。Apple 的動畫完成文件

這是一個完整的單檔 App,不需要素材或套件相依性。其 API 簽章已對照文件檢查;本研究環境未編譯或執行桌面 App。

import SwiftUI

@main
struct SpringReleaseDemoApp: App {
    var body: some Scene {
        WindowGroup {
            SpringReleaseView()
        }
    }
}

struct SpringReleaseView: View {
    @State private var offset: CGSize = .zero
    @State private var isReturning = false
    @GestureState private var isDragging = false

    var body: some View {
        VStack(spacing: 16) {
            Text("Drag the tile, then release")
                .font(.headline)

            ZStack {
                RoundedRectangle(cornerRadius: 20)
                    .stroke(.gray.opacity(0.4), lineWidth: 2)
                    .frame(width: 112, height: 112)

                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue)
                    .frame(width: 112, height: 112)
                    .contentShape(Rectangle())
                    .offset(offset)
                    .gesture(tileDrag)
                    .allowsHitTesting(!isReturning)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .coordinateSpace(name: "canvas")
        }
        .padding(24)
        .frame(width: 640, height: 440)
        .onChange(of: isDragging) { _, active in
            guard !active, !isReturning else { return }

            // Cancellation cleanup has no successful release value.
            withTransaction(Transaction(animation: nil)) {
                offset = .zero
            }
        }
    }

    private var tileDrag: some Gesture {
        DragGesture(
            minimumDistance: 3,
            coordinateSpace: .named("canvas")
        )
        .updating($isDragging) { _, active, _ in
            active = true
        }
        .onChanged { value in
            // Gesture callbacks automatically track velocity.
            offset = value.translation
        }
        .onEnded { _ in
            returnHome()
        }
    }

    private func returnHome() {
        guard !isReturning else { return }
        isReturning = true

        withAnimation(
            .spring(duration: 0.45, bounce: 0.2),
            completionCriteria: .removed
        ) {
            offset = .zero
        } completion: {
            isReturning = false
        }
    }
}

手勢附加在會移動的方塊上,而具名座標空間則屬於固定不動的畫布。如此一來,互動所採用的座標選擇便一目了然。Apple 的 DragGesture 文件 說明了如何透過手勢修飾器附加設定好的拖曳手勢。

value.translation 是從拖曳開始以來的總位移。將它指定給 offset;不要反覆將它加到先前的偏移量上,否則相同的移動會被重複累加。在此範例中,每次接受的拖曳都從原位開始,因此不需要另外儲存起始偏移量。Apple 對 translation 的定義

成功放開後會立即呼叫 returnHome()。它不會先重設偏移量,也不會延後安排彈簧動畫。如此可讓追蹤中的拖曳變更與放開動畫透過同一個狀態屬性保持連結。

為什麼要分開儲存拖曳、位置和返回狀態?

這三個屬性各自回答不同問題:offset 儲存要求的位移,isDragging 追蹤進行中的手勢,而 isReturning 記錄範例目前是否處於返回階段。這些值由方塊的擁有者在本地持有。Apple 建議將由檢視擁有的狀態宣告為 private,並只在其他檢視需要修改狀態時傳遞 binding。State 文件

包裝器語法也有特定用途。Swift 屬性包裝器會管理對包裝值的存取,也可以用美元符號前綴提供投影值。在此,$isDragging 會提供 updating 使用的手勢狀態投影;它不是另一個獨立維護的旗標。《Swift 程式語言:屬性》

取消手勢需要另外處理,因為只有在手勢成功完成時才會執行 onEnded。Apple 建議使用 @GestureState 儲存無論手勢如何結束都必須重設的狀態。此範例會觀察該狀態的重設,並在沒有正常返回過程進行時,將合成方塊還原。這個備援處理會讓方塊直接跳回原位;它不會假稱已取得已取消手勢的最終放開速度。手勢完成行為、GestureState

此範例也刻意限制只有在返回動畫完成後才能開始新的拖曳。將模型偏移量設為零時,動畫便會開始,因此在方塊仍可見地移動時,零已是要求的目的位置。若直接移除此限制,就必須設計新拖曳如何接手目前畫面上顯示的位置。

allowsHitTesting(!isReturning) 控制方塊是否參與命中測試。當整段動畫完成時,.removed 完成條件會解除這項限制。這樣就不必根據彈簧的 duration 參數猜測延遲時間。命中測試控制、動畫移除完成條件

如何調整並檢查放開效果?

先固定目的位置和彈簧設定。變更動畫參數之前,先改變放開方塊的方式。這樣才能將速度繼承效果與彈簧調整區分開來。

在視窗內依序進行以下檢查。這是根據文件中描述的交接方式整理出的診斷步驟,並非本範例的實測結果。

檢查項目 操作 留意事項
幾乎靜止時放開 將方塊拖離原位,稍作停留後放開。 返回動作開始時應幾乎沒有延續的移動。
向外移動時放開 將方塊拖離原位,並在仍向外移動時放開。 彈簧應先延續向外的移動,再帶它轉向返回。
向內移動時放開 將方塊拖離原位,反向朝原位拖曳後放開。 返回時應延續向內的移動。
斜向移動時放開 沿兩個軸移動時放開。 檢查交接是否保留移動方向,而不是只呈現水平返回。
下一次互動 等返回完成後,再次拖曳。 新的拖曳應從原位開始。

調整 duration 以改變速度,調整 bounce 以改變特性。本範例中的 0.45 和 0.2 只是示範用的起始值,並非指定設定。Apple 將 duration 定義為感知時長,並說明 bounce 為零時屬於臨界阻尼;正的 bounce 值則會產生越來越明顯的彈跳效果。彈簧設定

感知時長並不保證每個可見動作都會在某個精確時刻結束。Apple 將它與安定時長區分,後者取決於彈簧的運動狀態。此範例的返回階段應使用完成回呼,而不是把 0.45 當成計時器。使用彈簧製作動畫

如果放開時看起來沒有延續移動,請先檢查交接過程:手勢是否改變了彈簧動畫所使用的同一個偏移量?是否有其他程式碼在放開前覆寫偏移量?檢視是否被替換?是否有動畫修飾器也為拖曳更新加入動畫?這些檢查直接來自文件所述的要求:將變動中的可動畫屬性交給動畫之前,必須先追蹤這些屬性。

何時應明確讀取速度或使用 AppKit?

DragGesture.Value.velocity 可取得目前的拖曳速度,而 onEnded 會在成功完成後提供手勢的最終值。當速度會影響 App 的決策時,讀取速度就很有用,例如在兩個合成吸附位置之間做選擇。對於此範例中固定的返回目的位置,自動追蹤已足以完成動畫交接。拖曳速度、最終手勢值

若選擇使用明確的彈簧初始速度 API,請先確認它所需的數值表示法,再傳入手勢測量值。Apple 的手勢驅動動畫示範會將速度除以剩餘行程距離,轉換為單位速度。依照示範的計算方式,以每秒 300 點的速度移動剩餘 150 點的距離,會得到每秒 2 個動畫距離。剩餘距離為零時,也必須明確決定如何處理。此處採用 SwiftUI 自動追蹤的方式,便可避免這項計算。強化 UI 動畫與轉場

若既有 App 由 AppKit 檢視或辨識器擁有互動,請使用 AppKit。NSPanGestureRecognizer.velocity(in:) 會傳回相對於指定檢視、以每秒點數為單位的水平和垂直速度。雖然它的 Swift 簽章接受可選檢視,Apple 明確指出該引數不得為 nil。AppKit 平移手勢速度

macOS 26 和後續版本中,NSGestureRecognizerRepresentable 是將 AppKit 手勢辨識器整合至 SwiftUI 階層的文件所述方式。SwiftUI 會管理其建立與更新,並提供座標轉換支援。Apple 提醒,辨識器底層的檢視幾何形狀可能與 SwiftUI 檢視不同,因此跨越這個邊界時,請使用 representable 提供的轉換器。AppKit 手勢整合

對於方塊互動,請讓 SwiftUI 手勢獨自負責拖曳更新,並讓放開彈簧動畫呈現既有的偏移量。交接運作後,可在保留相同狀態與座標邊界的前提下,調整目的位置和彈簧參數。

來源