ドラッグ時に自分で実装できる明示的な配置ルールが必要なら、SwiftUI Canvasを選びましょう。図形を押し合ったり、跳ね返ったり、物理演算によって動き続けたりさせるなら、SpriteKitを選びましょう。まず、ドラッグ中の図形が別の図形に接触したときにどうなるかを決めてください。

このガイドでは、アプリが所有するワークスペース内でユーザーがドラッグする、メモリ内に作成した円や長方形を扱うMacアプリについて、操作方式のアーキテクチャを選ぶ方法を説明します。判断の基準は、衝突時の挙動、入力の振り分け、各図形の位置をどのコンポーネントが制御するかです。

2つの図形が接触したとき、どうなるべきか?

「衝突応答」は、いくつか異なる要件を指すことがあります。重なりを強調表示すること、無効な位置を拒否すること、2つのオブジェクトを跳ね返らせることには、それぞれ異なる仕組みが必要です。Appleは、接触した物体を報告するSpriteKitのコンタクトと、物理的な応答を計算してインパルスを適用する衝突とを明確に区別しています。衝突とコンタクトについて

必要な挙動 実装を始める方法 アプリ側で担う処理
ポインタで図形を自由に配置しながら、重なりを強調表示する 独自のジオメトリ判定を行うCanvas 選択判定、重なり検出、視覚的なフィードバック
最初の障害物で図形を止める 独自の移動制約を設けたCanvas 移動経路の検査と、許容する位置の決定
隣接する図形を押し、落ち着いたり跳ね返ったりさせる SpriteKit 物理演算の設定とドラッグコントローラー
各アイテムに通常のSwiftUIコントロールやジェスチャーを持たせる 個別のSwiftUIビュー アプリ固有の重なりや移動のルール

これらは、Appleが公開している機能から導いたアーキテクチャ上の提案であり、Appleが定めたパフォーマンスの基準ではありません。図形の数が少ないからといって必ずCanvasが適しているわけではなく、数が多いからといって必ずSpriteKitが必要になるわけでもありません。まず挙動を決め、その後、実際の利用状況を想定した数のアイテムで実装を計測してください。

もうひとつ、操作上の制約があります。ポインタへの厳密な追従と、重なりの完全な禁止は両立しないことがあります。ポインタが占有された位置を通る場合、アプリはアイテムの重なりを許すか、アイテムをポインタの後ろで止めるか、ほかのアイテムを動かす必要があります。描画方式を選ぶ前に、どの挙動にするかを決めてください。

Canvasでできることと、自分で実装する必要があること

Canvasはグラフィックスコンテキストを使った即時モードの描画を提供します。円、パス、シンボルは、それぞれ独立して操作できる要素にはなりません。またAppleによると、Canvasはビューとして渡されたシンボルも含め、個々に描画された要素のアクセシビリティを提供しません。そのため、Canvasに付けたジェスチャーでは、ユーザーがどのアイテムを選んだのかをアプリのロジックで判定する必要があります。Canvas

配置ルールが明確なエディタでは、図形をモデルに保持し、描画はそのモデルを映し出すものとして扱います。各アイテムには識別子、中心座標、寸法、描画順を持たせられます。Swiftのガイダンスでは、通常のデータには原則として構造体を使い、共有する識別性やフレームワークの継承が必要な場合にはクラスを使うことを推奨しています。値型のアイテムモデルとSpriteKitシーンのサブクラスは、それぞれ異なる役割を担います。構造体とクラスの選択

Canvasでのドラッグは、次のような手順で実装できます。

  1. ジェスチャーの開始時に、アイテムを描画順の逆順に調べ、開始位置が最初に一致したアイテムを選択します。
  2. ジェスチャー全体を通して、選択した識別子と元の中心座標を保持します。
  3. ジェスチャーの合計移動量を元の中心座標に加え、提案する中心座標を毎回計算します。
  4. ワークスペースの境界と衝突ルールを適用し、許容する中心座標を決めます。
  5. モデルを更新し、そのモデルからCanvasを再描画します。
  6. ジェスチャー終了時に選択状態を解除し、操作が中断された場合の後処理も定義します。

移動量が合計か増分かは重要です。SwiftUIのtranslationはジェスチャー開始時からの移動量を表します。すでに更新した中心座標に合計移動量を繰り返し加えると、移動量が過剰に累積します。毎回、保持しておいた開始時の中心座標を使ってください。SwiftUIではジェスチャーの値を通じてstartLocation、location、translationを利用でき、ジェスチャーの座標空間も指定できます。DragGesture.Value、DragGesture

ドラッグ中は選択状態を維持してください。ポインタが移動するたびに最初の選択判定を繰り返すと、別の図形の上を通過したときに選択アイテムが切り替わることがあります。同様に、ポインタと中心座標の間の開始時のずれを保てば、図形の中心が突然ポインタの下に来るような飛びを防げます。これらはアプリで定めるルールであり、Canvasが自動で提供する挙動ではありません。

例:重なりの検出と、移動を止める処理

例として、半径がそれぞれ20ポイントの円を2つ考えます。円Aの開始位置は(100, 100)で、静止している円Bの位置は(200, 100)です。ユーザーがAの中心を(190, 100)に移動しようとします。このとき2つの中心間の距離は10ポイントですが、半径の合計は40ポイントなので、この候補位置では重なります。

重なりを示すだけなら、候補位置を許可して描画を変更します。横方向のドラッグを止める場合、最初に接触する位置は(160, 100)です。このとき中心間の距離は40ポイントです。この計算は、Bが固定されたままで、2つの円の大きさが変わらず、Aが水平方向の経路をたどることを前提としています。

ここで、ポインタの1回の更新が(260, 100)を要求したとします。終点だけを検査すると重なりは見つかりませんが、移動経路はBを通過しています。ドラッグを止める実装には、移動範囲全体の交差判定など、途中の経路を検査する明示的な方法が必要です。重なっている終点だけを拒否しても、「最初の障害物で止める」処理にはなりません。

この例が、衝突応答を正確に定義するべき主な理由です。端点のジオメトリ判定をいくつか実装すれば、重なりの強調表示は完成するかもしれません。障害物を避けるスライド、同時接触、隣接する図形の移動に対応すると、独自のソルバーは大幅に複雑になります。Canvasを描画に使う場合も、そうした追加の挙動はアプリ側で担います。

SpriteKitのほうが適しているのはどんな場合?

衝突時の挙動をシミュレーションによって生み出したい場合は、SpriteKitが有力です。ノードにSKPhysicsBodyを付けると、重力、摩擦、衝突などを計算でき、シミュレーションがノードの位置と向きを更新します。オブジェクト用の体積ボディと境界用のエッジボディに対応しています。SKPhysicsBody

最初のプロトタイプには、間隔を空けた円を2つと、長方形のワークスペース境界を使ってください。円には同じ形状のボディを設定し、卓上のようなワークスペースなら重力を無効にします。また、回転が操作に必要かどうかも決めます。摩擦、反発係数、減衰は、デフォルト値に任せず、意図に沿って設定してください。これらのプロパティは、それぞれ物理挙動の異なる側面を決めます。SKPhysicsBody

SwiftUIではSpriteViewを使ってシーンを直接表示できます。Appleの説明によると、SpriteViewはSpriteKitのシーンを描画するSwiftUIビューです。ドキュメント上のMacでの利用可能バージョンはmacOS 11以降で、CanvasはmacOS 12以降です。そのため、操作の挙動が主な判断基準である一方、デプロイ先のバージョンも選択に関わります。SpriteView、Canvas

衝突応答と通知を別々に設定する

図形と境界にカテゴリを割り当て、次の2種類のマスクを明示的に設定します。

  • categoryBitMaskは、ボディが属するカテゴリを示します。
  • collisionBitMaskは、そのボディに物理的な影響を与えられるカテゴリを選択します。
  • contactTestBitMaskは、選択したカテゴリとの接触通知を要求します。

衝突マスクは、ボディごとに独立して評価されます。2つの動的な図形を通常どおり相互に反応させるには、両方のボディが相手のカテゴリに反応するよう設定します。コンタクトの通知には別のルールがあり、カテゴリとコンタクトマスクの比較がどちらか一方で一致すれば、通知が生成されます。通知を有効にしただけでは、物理的な衝突は設定されません。collisionBitMask、contactTestBitMask

ボディが物理的に反応するのにコールバックが届かない場合は、コンタクトマスクと物理ワールドのコンタクトデリゲートを確認してください。図形がすり抜けるのにコールバックが届く場合は、衝突マスクと移動コントローラーを調べてください。こうすれば、通知の設定と実際の移動の問題を切り分けられます。

ユーザーがドラッグしている間、位置を制御するのはどこか?

SpriteKitでドラッグを実装するには、明示的なコントローラーが必要です。物理エンジン自体にポインタ追従機能があるわけではありません。前述の挙動に応じて、直接配置するか、シミュレーション駆動の移動を使うかを選んでください。

直接配置する場合は、アプリのコードがドラッグ中のノードの位置を設定します。ポインタがアイテムを正確に配置する必要がある場合に適していますが、連続する位置の間の経路が物理的に解決されるわけではありません。isDynamicを無効にすると、ボディは力やインパルスを無視しますが、ドラッグ用ソルバーが作られるわけではありません。このプロパティを一時的に変更する場合は、いつ元に戻すか、ドラッグ後に動きを維持するか消すかを定義してください。isDynamic

シミュレーション駆動でドラッグする場合は、ポインタの位置を目標として保持し、力やバネベースのコントローラーを使って動的ボディを誘導します。これにより、ボディの移動中にシミュレーションが接触を解決できます。その代わり、別のオブジェクトが進路をふさいだとき、意図的にポインタへの追従に遅れが出たり、ポインタからずれたりします。SpriteKitでは力を適用でき、SKPhysicsJointSpringは2つの物理ボディをバネの挙動で接続します。いずれもアプリに合わせた調整が必要な構成要素です。SKPhysicsBody、SKPhysicsJointSpring

物理演算が同じ位置を独立して制御することを期待しながら、ポインタ位置を連続的に割り当てるのは避けてください。SpriteKitでドキュメント化されているフレーム処理では、アプリの更新、アクション、物理演算、その後のコールバックが別々の段階で実行されます。入力がドラッグの目標を更新し、シーンのロジックがコントローラーを適用し、物理演算がボディの結果位置を決める、という役割分担が有効です。フレームサイクルイベントへの応答

コンタクトのコールバックにも制約があります。Appleによると、コールバックは物理シミュレーションの処理中に実行され、その間は物理ワールドを変更できず、シミュレーション対象のボディを変更した場合の挙動も未定義です。コールバックでは保留中の処理を記録し、Appleのフラグと更新のパターンに従って、次の更新時に物理設定を変更してください。SKPhysicsContactDelegate

macOSの入力をフレームワーク間でどう受け渡すか?

ワークスペースの入力を担当する場所は1つに決めてください。Canvasでは、操作面にSwiftUIのDragGestureを付けられます。独自のAppKit操作面ではレスポンダーイベントを処理でき、mouseDragged(with:)は左マウスボタンを押したままの移動を受け取ります。重ねて配置したSwiftUIジェスチャーとAppKitのハンドラーが、同じ選択アイテムを別々に動かす構成は避けてください。DragGesture、mouseDragged(with:)

AppKitからSpriteKitへのブリッジでは、境界ごとに座標を変換します。NSEvent.locationInWindowは通常、関連するウィンドウのベース座標系で表されます。その点をホストビューの座標に変換し、次にビュー座標からシーン座標へ変換します。後者の変換にはAppleがconvertPoint(fromView:)を提供しており、呼び出す前にシーンが表示されている必要があります。ノードの位置は親の座標系で表されるため、ノードが入れ子になっている場合は追加の変換が必要です。locationInWindow、convertPoint(fromView:)、position

SwiftUIで簡単に表示するにはSpriteViewを使います。独自のAppKitビューが必要なら、NSViewRepresentableを使って作成、更新、調整、破棄を行えます。表示対象のビューのレイアウトはSwiftUIに任せてください。Appleは、管理対象ビューのフレームや境界を自分で設定しないよう明示的に警告しています。NSViewRepresentable

SwiftUIがビューの内容を再計算するたびに新しいシーンを作るのではなく、安定した所有者にシーンを保持してください。Canvasでも同様にアイテムモデルを保持します。SwiftUIのモデルに関するドキュメントでは、管理された状態によってビューの再作成後もインスタンスを保持する方法が説明されています。Observationを使うにはmacOS 14以降が必要です。シーンのコマンドとモデルのスナップショットは意図を明確にして受け渡し、位置の決定をめぐって2つの可変コピーが競合しないようにしてください。アプリでのモデルデータの管理

どちらを選ぶか決める前に、何をテストすべきか?

見た目を整える前に、小さなテスト用ワークスペースで難しい操作を確認してください。

  1. 図形の端に近い位置をつかみ、開始時のずれが維持されることを確認します。
  2. 別のアイテムにゆっくりドラッグして接触させた後、ポインタを大きく一度に動かして同じテストを行います。
  3. 隣り合う2つの図形がある角に向けてドラッグします。
  4. 別のアイテムに接触した状態でボタンを離し、想定どおりに落ち着くかを確認します。
  5. ワークスペースのサイズを変更し、選択判定、境界、座標変換を確認します。
  6. 周囲のSwiftUIコンテンツを再作成し、アイテムの位置が維持されることを確認します。
  7. ドラッグを中断し、選択状態と一時的な物理設定が後処理されることを確認します。

SpriteKitでは、ボディ形状が意図した衝突範囲と一致するかを確認するときにSKView.showsPhysicsを有効にしてください。サイズが小さく高速で動くボディでは、usesPreciseCollisionDetectionを検討してください。Appleの説明によると、これは計算コストが高く、通常のフレームごとの判定では見逃す衝突の検出に役立ちます。ただし、適切なドラッグコントローラーの代わりにはなりません。SKView、usesPreciseCollisionDetection

配置ルールが明確で、範囲が限定され、直接表現しやすい場合はCanvasを選びましょう。図形同士の物理的な相互作用が必要な場合はSpriteKitを選びましょう。どちらを実装する場合も、安定したデスクトップのドラッグ操作には、選択アイテムが1つであること、座標変換が明示されていること、各位置の更新を担当するコンポーネントが1つだけであることが必要です。

参考資料