SwiftUIがドラッグをプレビューしている間は図形の確定位置を変更せず、onEndedで最終的な移動量を確定します。これにより、AppKitのマウスボタンを離すタイミングに依存せず、完了まで開始位置を保持できます。Macアプリで長方形を移動する完全な例を紹介します。
開始時の状態を保持するのは、どの完了境界ですか?
図形を移動する操作では、すべてのプレビューと最終位置を同じ開始位置から計算することが具体的な要件です。適切な完了境界は、その図形のジェスチャーが正常に完了した時点です。Appleの説明によると、onEnded(_:)はジェスチャーの最終値を渡し、ジェスチャーが正常に終了した場合にのみ実行されます。また、ジェスチャーがどのように終了してもリセットする必要がある一時的な状態には、@GestureStateを推奨しています。AppleのonEnded(_:)のドキュメント。
この操作では、確定済みのモデル位置と、一時的なプレビュー移動量という2種類の状態を持たせます。ドラッグ中は両者の合計を表示します。正常に完了したら、最終的な移動量を確定位置に一度だけ適用します。これは、一時的なジェスチャー更新と永続的な状態更新を区別するAppleの説明から導かれる設計です。ジェスチャーでインタラクティブ性を追加する。
| 情報 | この例での管理元 | 有効期間 |
|---|---|---|
| 長方形の確定済み中心位置 | Privateな@Stateモデル |
ジェスチャー完了後も維持される |
| 現在のプレビュー移動量 | @GestureState |
ジェスチャーが非アクティブになるとリセットされる |
| 最終的な移動量 | onEndedのコールバック引数 |
移動の確定に同期的に使用される |
| AppKitのマウスボタンを離すイベント | AppKitのイベント処理 | ボタンが離されたことを通知する。SwiftUIジェスチャーの完了は定義しない |
Appleは、@StateをSwiftUIが管理するストレージであり、ビュー階層内の値の信頼できる情報源として説明しています。@GestureStateは、そのジェスチャーが非アクティブになると自動的にリセットされます。前者に基準位置を、後者にプレビューを保持すれば、それぞれの値にこの操作で必要な有効期間を持たせられます。State、GestureState。
長方形をドラッグするMacアプリの完全な例を作成する
Xcodeで、デプロイメントターゲットをmacOS 14以降に設定したmacOS SwiftUIアプリを作成します。生成されたアプリ宣言とコンテンツビューを、以下の単一ファイルに置き換えてください。ターゲット内の@main宣言は1つだけにします。この例では、メモリ上だけに保存する模擬の長方形を1つ使います。名前付き座標空間のAPIはmacOS 14以降で利用可能とドキュメントに記載されています。ドラッグジェスチャーのイニシャライザー、名前付き座標空間。
import SwiftUI
@main
struct GestureStartDemoApp: App {
var body: some Scene {
WindowGroup {
ShapeCanvas()
}
}
}
private struct SampleItem {
var center = CGPoint(x: 160, y: 140)
}
private struct ShapeCanvas: View {
@State private var item = SampleItem()
@GestureState private var previewTranslation = CGSize.zero
private let canvasWidth: CGFloat = 600
private let canvasHeight: CGFloat = 360
private let shapeWidth: CGFloat = 80
private let shapeHeight: CGFloat = 60
private func projectedCenter(_ translation: CGSize) -> CGPoint {
let proposedX = item.center.x + translation.width
let proposedY = item.center.y + translation.height
let halfWidth = shapeWidth / 2
let halfHeight = shapeHeight / 2
return CGPoint(
x: min(max(proposedX, halfWidth),
canvasWidth - halfWidth),
y: min(max(proposedY, halfHeight),
canvasHeight - halfHeight)
)
}
private var moveGesture: some Gesture {
DragGesture(
minimumDistance: 3,
coordinateSpace: .named("shapeCanvas")
)
.updating($previewTranslation) { value, translation, _ in
translation = value.translation
}
.onEnded { value in
let finalCenter = projectedCenter(value.translation)
item.center = finalCenter
}
}
var body: some View {
let displayedCenter = projectedCenter(previewTranslation)
VStack(alignment: .leading, spacing: 12) {
Text("Drag the blue rectangle")
.font(.headline)
ZStack {
Color.gray.opacity(0.12)
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.frame(width: shapeWidth, height: shapeHeight)
.contentShape(Rectangle())
.gesture(moveGesture)
.position(
x: displayedCenter.x,
y: displayedCenter.y
)
}
.frame(width: canvasWidth, height: canvasHeight)
.coordinateSpace(.named("shapeCanvas"))
.clipped()
Text(
"Committed center: " +
"\(Int(item.center.x)), \(Int(item.center.y))"
)
.monospacedDigit()
}
.padding(20)
}
}
重要な不変条件は、item.centerがupdating内で変更されないことです。onEndedがfinalCenterを計算するまで、ジェスチャーのモデル基準位置として保持されます。最終計算はvalue.translationを直接読み取るため、完了時にプレビュー用プロパティが最後の値を保持している必要はありません。
ジェスチャーは、親キャンバスの名前付き座標空間で移動量を測定します。長方形のposition(x:y:)は、その同じ親座標空間に中心を配置します。座標空間を一致させれば、移動する図形のローカル座標とキャンバス座標の混在を避けられます。Appleは、名前付き空間でのジェスチャー測定と中心基準の位置指定の両方を説明しています。coordinateSpace(_:)、position(x:y:)。
共通のprojectedCenter関数は、長方形がキャンバス内に収まるようにします。プレビューと確定では同じ境界を使用します。水平方向の中心は40から560まで、垂直方向の中心は30から330までです。端に達しても、移動量はドラッグ開始時からの合計変位を基準にします。提案位置が境界内に戻れば、長方形は端から離れて移動します。
設定した3ポイントの認識しきい値より小さい移動は、認識されたドラッグとはみなされません。この例では、認識された図形移動操作の開始時点を、意図的に開始位置として扱います。物理的なマウスボタン押下時にモデル状態を取得する要件は、別の境界に関するものです。ドラッグジェスチャーのイニシャライザー。
最終更新まで開始位置が保持される理由
Appleは、DragGesture.Value.translationをジェスチャー開始から現在のイベントまでの合計移動量と定義しています。直前のコールバックからの差分ではなく、累積値です。そのため、更新済みのモデル位置に報告された移動量を毎回加えると、移動量を過剰に数えることになります。DragGesture.Value。
次の値は説明用の入力であり、すべてキャンバスの境界内にあります。
| 段階 | 報告された合計移動量 | 確定中心位置 | 表示中心位置 |
|---|---|---|---|
| ドラッグ前 | (0, 0) |
(160, 140) |
(160, 140) |
| 最初の更新 | (12, 5) |
(160, 140) |
(172, 145) |
| 2回目の更新 | (30, -10) |
(160, 140) |
(190, 130) |
| 正常終了 | (45, 20) |
(205, 160)になる |
(205, 160)に落ち着く |
すべてのプレビューは同じ基準位置を使います。完了時には最終的な(45, 20)を一度だけ適用します。次のドラッグが(-15, 10)の移動量で正常に完了すると、(205, 160)から開始し、(190, 170)を確定します。これらの計算は、決定性のあるPython計算で確認しました。取得したAPIドキュメントに照らしてSwiftUIアプリの内容を確認しましたが、この環境ではコンパイルも実行もしていません。
長方形の開始中心位置としてstartLocationを代用しないでください。AppleはstartLocationをドラッグの最初のイベントの位置と定義しています。ポインターが長方形の角付近から始まることがあるため、その位置と図形のモデル上の中心は異なる場合があります。同様に、predictedEndTranslationは速度に基づく予測値です。この直接配置の操作では、最終的なtranslationを使います。DragGesture.Value。
この設計では、プレビュー更新が確定位置を上書きしないため、キャンセル時に確定位置を元に戻す必要はありません。Appleの説明によると、ジェスチャーが非アクティブになるとジェスチャー状態がリセットされ、onEndedは正常に完了した後にのみ実行されます。そのため、一時的な移動量はキャンセルされた移動を確定せずに消えます。この結論はドキュメントに記載された動作から導かれるもので、リセットと正常終了コールバックの順序を仮定する必要はありません。GestureState、onEnded(_:)。
AppKitのマウスボタンを離す監視処理で基準位置を消去しない理由
AppKitのローカルイベント監視処理は、イベントがアプリにディスパッチされる前にイベントを受け取ります。監視対象のマウスボタンを離すイベントを使って共有のジェスチャー開始状態を消去すると、後続の処理がそのイベントを扱う前に状態が削除される可能性があります。これはドキュメントに記載されたディスパッチ境界から導かれるもので、SwiftUIコールバックの呼び出し順序が保証されているわけではありません。addLocalMonitorForEvents(matching:handler:)。
同様に、NSResponder.mouseUp(with:)が通知するのは、ユーザーがマウスの左ボタンを離したことです。その仕様には、SwiftUIのすべてのジェスチャーコールバックやビュー更新が完了したことは定められていません。生のイベントの監視と、認識されたジェスチャーの完了は、それぞれ別の役割として扱ってください。mouseUp(with:)。
ここで確認したAPIは、SwiftUIが生イベントに関連するすべての処理を終えたことを意味する一般的なコールバックを定義していません。キューに入れたクリーンアップ処理や短いタイマーを使っても、スケジュールに関する仮定が増えるだけで、その関係は確立されません。このタスクでは、最終的なジェスチャー値が得られるonEnded内で、モデルの最終計算を行うのがサポートされた方法です。
カスタムのAppKitビューで操作を実装する必要がある場合は、そのビューに入力シーケンスとセッション状態を持たせ、NSViewRepresentableを通じて統合してください。Appleは、この統合のために生成、更新、コーディネーター、破棄の各フックを提供しています。ラップ対象のビューのセッションはAppKitビューまたはそのコーディネーターに保持し、representableの値ごとに作り直さないでください。SwiftUIがラップ対象のビューのレイアウトを制御するため、管理対象のフレームや境界を独自に変更しないでください。NSViewRepresentable。
この操作に明示的なスナップショットが必要なのはどのような場合ですか?
ドラッグ中にほかの処理が確定位置を変更しないため、この例では確定位置だけで十分です。より大きなキャンバスで別のセッションオブジェクトを導入する前に、次の判断基準を使ってください。
| 要件 | 設計上の対応 |
|---|---|
| プレビュー中に確定位置が変わらない1つのアイテムを移動する | ここで示した基準位置とプレビューの方式を使う |
| 選択した複数のアイテムをまとめて移動する | 安定した識別子と開始位置を、1つの操作スナップショットとして保持する |
| ドラッグ中にほかのコマンドで位置を編集できるようにする | 編集をブロックするか、ドラッグをキャンセルするか、モデルのリビジョンに基づいて解決するかを決める |
| ジェスチャー認識前の物理的なマウスボタン押下時に状態を取得する | 明示的に管理するAppKitの入力境界を使う。最初のジェスチャー更新は同等ではない |
| 完了後も別のセッションを維持する | 一時的なジェスチャー状態に依存せず、明示的な管理元と終了方針を定める |
スナップショットには値を保持する必要があります。Swiftの構造体には値セマンティクスがある一方、クラスのインスタンスを代入すると参照がコピーされます。そのため、変更可能なアイテムオブジェクトへの参照を並べた配列だけでは、位置は固定されません。複数のアイテムを移動する場合は、最終計算に必要な識別子と座標値を取得してください。構造体とクラス — The Swift Programming Language。
永続的なセッションを導入する場合は、正常終了、キャンセル、ビュー削除を明示的に定義してください。onEndedでのみセッションを消去すると、そのコールバックは正常終了時にしか実行されないため、キャンセルへの対応が漏れます。ジェスチャー状態のリセットトランザクションを、あらゆる完了を通知する仕組みとして流用しないでください。Appleの説明によると、リセットクロージャはジェスチャー状態をリセットするためのトランザクションを受け取ります。onEnded(_:)、init(wrappedValue:reset:)。
通常の長方形移動では、プレビュー中に確定モデルを変更しないことで基準位置を保持します。正常に完了したジェスチャー自身の値から最終位置を計算して一度だけ確定し、一時的なジェスチャー状態はリセットさせます。これにより、計算に必要な間、開始位置の管理元と有効期間が明確になります。
出典
- ジェスチャーでインタラクティブ性を追加する — Apple。
- State — Apple。
- GestureState — Apple。
- onEnded(_:) — Apple。
- DragGesture.Value — Apple。
- init(minimumDistance:coordinateSpace:) — Apple。
- coordinateSpace(_:) — Apple。
- position(x:y:) — Apple。
- addLocalMonitorForEvents(matching:handler:) — Apple。
- mouseUp(with:) — Apple。
- NSViewRepresentable — Apple。
- 構造体とクラス — The Swift Programming Language — Swift.orgプロジェクト。
- init(wrappedValue:reset:) — Apple。