Mac アプリ内でシェイプをドラッグする場合、ジェスチャー中はオフセットを直接更新し、解放時に同じオフセットをスプリングでアニメーションさせます。macOS 14 以降では、SwiftUI が記録したドラッグ速度を自動的にスプリングへ引き継げます。

このチュートリアルでは、小さなデスクトップ操作を作成します。青いタイルを任意の方向へドラッグして離すと、スプリングで開始位置に戻ります。状態はすべてメモリ内に保持され、ジェスチャーはアプリ自身のウィンドウ内にあるタイルに属します。

スプリングはドラッグの最終速度をどう引き継ぐのか?

SwiftUI の Transaction.tracksVelocity は、アニメーション可能なプロパティの変化に伴う速度を記録します。Apple のドキュメントによると、ジェスチャーの onChanged コールバックと updating コールバックでは、この追跡が自動的に有効になります。操作が終わると、アニメーションで蓄積された速度を利用できます。そのため、通常の SwiftUI の操作では初期速度を計算して渡す必要はありません。Apple の速度追跡に関するドキュメントでは、この引き継ぎが明示的に説明されています。

重要なのは、アニメーションさせるプロパティです。ドラッグ中は、ジェスチャーの移動量をタイルのオフセットに書き込みます。解放時は、withAnimation の中で同じオフセットを目的の位置に変更します。ビューとその状態を維持すれば、SwiftUI が追跡できるプロパティが途切れずに保たれます。

この実装では、ドラッグ中の更新をアニメーションさせません。Apple は、トランザクションによる速度追跡と、アニメーション自身が速度を管理する方法は併用できないと説明しています。ドラッグ中にインタラクティブスプリングを使う方法もありますが、タイルがポインタに追従する動きが変わります。ここでの直接追従の操作には必要ありません。トランザクションによる速度追跡

スプリングの目的位置と初期の動きは、それぞれ役割が異なります。目的位置はタイルがどこに落ち着くかを示します。引き継がれた速度は、解放時にタイルがどのように動いているかを示します。元の位置から離れる方向へ動いているときにタイルを解放すると、スプリングが引き戻す前に、タイルがその方向へ動き続ける場合があります。Apple は Animate with springs で、ジェスチャーの速度を引き継いで動き続けるスプリングを紹介しています。

macOS でのドラッグと解放の一連の例を作成する

Xcode で SwiftUI を使った macOS アプリを作成し、デプロイメントターゲットを macOS 14.0 以降に設定して、生成されたアプリのエントリーファイルを次のコードに置き換えます。ターゲット内の @main アプリ宣言は1つだけにしてください。この例では macOS 14 のアニメーション完了 API を使います。Apple のアニメーション完了に関するドキュメント

これは、アセットやパッケージ依存関係のない、単一ファイルで完結するアプリケーションです。API のシグネチャはドキュメントで確認していますが、この調査環境ではデスクトップアプリをコンパイルも実行もしていません。

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() が呼び出されます。その前にオフセットをリセットしたり、後からスプリングを開始するよう予約したりはしません。これにより、追跡中のドラッグによる変化と解放時のアニメーションが、同じ状態プロパティを通じてつながります。

ドラッグ、位置、復帰の状態を分けておく理由

3つのプロパティはそれぞれ異なる情報を表します。offset は要求された移動量を保持し、isDragging はジェスチャーが進行中かどうかを追跡し、isReturning はこの例の復帰段階を記録します。タイルを所有するビューがこれらの値をローカルに保持します。Apple は、ビューが所有する状態は private として宣言し、別のビューから変更する必要がある場合にのみバインディングで共有することを推奨しています。State のドキュメント

ラッパーの構文にも明確な目的があります。Swift のプロパティラッパーはラップされた値へのアクセスを管理し、ドル記号の接頭辞で射影値を公開できます。ここでは $isDragging が updating で使うジェスチャー状態の射影値を提供します。これは独立して管理される2つ目のフラグではありません。The Swift Programming Language: Properties

キャンセル時には別の処理が必要です。onEnded が実行されるのは、ジェスチャーが正常に完了した場合だけだからです。Apple は、ジェスチャーがどのように終了してもリセットする必要のある状態には @GestureState を推奨しています。この例では、そのリセットを監視し、通常の復帰処理中でなければ疑似タイルを元に戻します。このフォールバックではホーム位置に瞬時に戻します。キャンセルされたジェスチャーに最終解放速度があったとは扱いません。ジェスチャー完了時の動作、GestureState

また、復帰アニメーションが終わるまで新しいドラッグを受け付けないようにしています。モデルのオフセットをゼロに設定するとアニメーションが始まるため、タイルが画面上でまだ動いている途中でも、要求された目的地はすでにゼロです。この制限を単に取り除く場合は、新しいドラッグが現在表示されている位置をどう引き継ぐかを設計する必要があります。

allowsHitTesting(!isReturning) はタイルがヒットテストの対象になるかを制御します。.removed の完了条件により、アニメーション全体が終わった時点で制限が解除されます。これにより、スプリングの duration パラメーターから時間を推測する必要がありません。ヒットテストの制御、アニメーションの削除完了

解放時の動きを調整して確認するには?

まず、目的位置とスプリング設定を固定します。アニメーションのパラメーターを変える前に、タイルの解放方法を変えてください。そうすれば、速度の引き継ぎとスプリングの調整を切り分けられます。

ウィンドウ内で次の確認を行います。これはドキュメントに記載された引き継ぎ方法から導いた診断手順であり、この例で測定した結果ではありません。

確認項目 操作 確認する点
ほぼ静止した状態で解放 ホーム位置からドラッグして離れ、いったん止まってから解放します。 引き継がれる動きがほとんどない状態で復帰を開始するはずです。
外向きに動きながら解放 ホーム位置からドラッグし、外向きに動いている間に解放します。 戻る向きに転じる前に、スプリングが外向きの動きを引き継ぐはずです。
内向きに動きながら解放 ホーム位置からドラッグし、ホームに向かって方向を変えてから解放します。 復帰時に内向きの動きが続くはずです。
斜め方向に解放 2つの軸に沿って動きながら解放します。 水平方向だけの復帰のように動くのではなく、方向が引き継がれることを確認します。
次の操作 復帰の完了を待ってから、もう一度ドラッグします。 新しいドラッグがホーム位置から始まるはずです。

duration でテンポを、bounce で動きの特性を調整します。この例の 0.45 と 0.2 は説明用の初期値であり、推奨設定ではありません。Apple は duration を知覚上の継続時間と定義し、バウンスゼロを臨界減衰として説明しています。正のバウンス値では、値が大きくなるほどスプリングの弾みが強くなります。スプリングの設定

知覚上の継続時間は、目に見える動きがすべていつ終わるかを厳密に保証するものではありません。Apple はこの値と、スプリングの動きに応じて決まる整定時間を区別しています。この例の復帰段階には完了コールバックを使い、0.45 をタイマーとして扱わないでください。Animate with springs

解放しても動きが無視されるように見える場合は、まず引き継ぎを確認してください。ジェスチャーは、スプリングがアニメーションさせるものと同じオフセットを変更していますか? 別のコードが解放前にそのオフセットを上書きしていませんか? ビューが置き換えられていませんか? アニメーションモディファイアがドラッグ中の更新をアニメーションさせていませんか? これらの確認事項は、アニメーション可能なプロパティの変化を追跡してからアニメーションに引き継ぐという、ドキュメントの要件に基づいています。

速度を明示的に読み取る場合や AppKit を使う場合

DragGesture.Value.velocity は現在のドラッグ速度を公開し、onEnded は正常終了後にジェスチャーの最終値を提供します。速度がアプリケーションの判断に影響する場合、たとえば2つの疑似スナップ位置から選ぶ場合には、速度を読み取ると便利です。この例のように目的地が固定の復帰では、自動追跡ですでにアニメーションへ速度が引き継がれます。ドラッグ速度、ジェスチャーの最終値

初期速度を指定するスプリング API を明示的に使う場合は、ジェスチャーの測定値を渡す前に、その表現形式を確認してください。Apple のジェスチャー駆動アニメーションのデモでは、残りの移動距離で速度を割って、速度を単位速度に変換しています。例示の計算では、毎秒 300 ポイントの速度と 150 ポイントの移動距離から、毎秒 2 アニメーション距離が得られます。この変換では、残りの距離がゼロの場合の扱いも意図的に決める必要があります。ここでは SwiftUI の自動処理を使うことで、この計算を避けられます。Enhance your UI animations and transitions

アプリが所有する既存の AppKit ビューや認識器に操作を任せる必要がある場合は、AppKit を使います。NSPanGestureRecognizer.velocity(in:) は、指定したビューを基準に、ポイント毎秒で水平方向と垂直方向の速度を返します。Swift のシグネチャではビューが省略可能ですが、Apple はこの引数に nil を指定してはならないと明記しています。AppKit のパン速度

macOS 26 以降では、SwiftUI の階層に AppKit のジェスチャー認識器を統合する方法として、NSGestureRecognizerRepresentable がドキュメントに記載されています。SwiftUI がその作成と更新を管理し、座標変換のサポートも提供します。認識器の基盤となるビューは SwiftUI ビューと異なる形状の場合があるため、境界をまたぐときは representable の変換機能を使うよう Apple は注意を促しています。AppKit ジェスチャーの統合

このタイル操作では、ドラッグ更新を所有するものを SwiftUI のジェスチャー1つに保ち、解放時のスプリングで既存のオフセットをアニメーションさせます。この引き継ぎが機能したら、同じ状態と座標系の境界を保ったまま、目的位置とスプリングのパラメーターを調整します。

出典