Article
📐 SwiftUIのドラッグ吸着を壊さない設計:ズーム・完全一致・Undo/Redo
図形エディターで、図形をドラッグしたときにガイド線へ「ピタッ」と合わせる機能を**スナップ(吸着)**と呼びます。見た目は簡単ですが、ズームすると吸着しづらくなったり、すでに揃った図形が別のガイドへ飛んだり、キャンセルした操作がUndoに残ったりしがちです。
この記事では、SwiftUIで作る図形エディターを例に、座標系・候補選択・操作の確定タイミングを分ける考え方を紹介します。例は理解しやすくするための最小構成です。実際のアプリのソースをそのまま転載したものではありません。
まず「図面の単位」と「画面上の距離」を分ける
たとえば刺しゅう図案の座標はmm(ミリメートル)、iPad画面上の操作距離はpt(ポイント)で管理するとします。ptはSwiftUIなどで使用する論理上の画面単位で、物理ピクセルと同じとは限りません。
同じ8ptの操作でも、ズーム倍率によって図面上の移動距離は変わります。そこで指の操作の許容距離はptで固定し、判定直前にmmへ変換します。
struct CanvasScale {
// 画面上の1mmが何ptに当たるか。必ず0より大きい値を渡す
let pointsPerMillimeter: Double
func snapThresholdMillimeters(for screenPoints: Double = 8) -> Double {
precondition(pointsPerMillimeter > 0)
return screenPoints / pointsPerMillimeter
}
func dragMillimeters(from translationPoints: Double) -> Double {
precondition(pointsPerMillimeter > 0)
return translationPoints / pointsPerMillimeter
}
}
assert(CanvasScale(pointsPerMillimeter: 4)
.snapThresholdMillimeters() == 2)
assert(CanvasScale(pointsPerMillimeter: 16)
.snapThresholdMillimeters() == 0.5)
前者は8ptが2mm、拡大した後者は8ptが0.5mmです。つまり画面では同じ距離に近づいたときに吸着するようになります。回転・スクロール・原点移動もあるエディターでは、単純な割り算ではなく表示変換の逆変換で座標を揃えてください。
SwiftUIのDragGesture.Value.translationは、ドラッグ開始からの移動をCGSizeで返します。これを現在位置と取り違えたり、毎回前回値へ累積加算したりしないことも重要です(末尾のApple公式資料を参照)。
ガイド線上の一番近い点を求める
水平・垂直だけでなく斜めのガイドも扱うなら、点を直線へ正射影(最短距離になる位置へ下ろすこと)します。ガイドを「線上の1点 origin」と「方向ベクトル direction」で表します。
以下は無限に伸びる直線の例です。線分へ吸着させる場合は射影係数tを0...1へ制限するなど、別の処理が必要です。
struct Vec2: Equatable {
let x: Double
let y: Double
}
struct Guide {
let origin: Vec2
let direction: Vec2
}
func footOfPerpendicular(from point: Vec2, to guide: Guide) -> Vec2? {
let d = guide.direction
let lengthSquared = d.x * d.x + d.y * d.y
guard lengthSquared > 0 else { return nil } // 方向がゼロのガイドは無効
let dx = point.x - guide.origin.x
let dy = point.y - guide.origin.y
let t = (dx * d.x + dy * d.y) / lengthSquared
return Vec2(
x: guide.origin.x + t * d.x,
y: guide.origin.y + t * d.y
)
}
func snap(
_ point: Vec2,
guides: [Guide],
thresholdMillimeters: Double
) -> Vec2? {
precondition(thresholdMillimeters >= 0)
var nearest: (point: Vec2, distanceSquared: Double)?
let limitSquared = thresholdMillimeters * thresholdMillimeters
for guide in guides {
guard let candidate = footOfPerpendicular(from: point, to: guide) else {
continue
}
let dx = point.x - candidate.x
let dy = point.y - candidate.y
let distanceSquared = dx * dx + dy * dy
guard distanceSquared <= limitSquared else { continue }
if nearest == nil || distanceSquared < nearest!.distanceSquared {
nearest = (candidate, distanceSquared)
}
}
return nearest?.point
}
Vec2?はSwiftのOptional型で、「座標がある」か「候補がない(nil)」を表します。この戻り値が、次の落とし穴を防ぎます。
ハマりどころ:補正量0を「候補なし」にしてはいけない
すでに図形がガイド上にあると、補正量は(0, 0)です。ここでif dx == 0 && dy == 0 { continue }のように候補を捨てると、他のガイドが近くにある場合に図形がわざわざ別の位置へずれることがあります。
nilは「該当するガイドがなかった」、座標が返る場合は「一致済みを含め、有効な候補がある」と区別します。
let vertical = Guide(
origin: Vec2(x: 5, y: 0),
direction: Vec2(x: 0, y: 1)
)
let guideList = [vertical]
// すでに一致していても、nilではなく同じ座標を返す
assert(snap(Vec2(x: 5, y: 3), guides: guideList,
thresholdMillimeters: 0.5) == Vec2(x: 5, y: 3))
// 近傍ならガイドへ寄せる
assert(snap(Vec2(x: 5.3, y: 3), guides: guideList,
thresholdMillimeters: 0.5) == Vec2(x: 5, y: 3))
// 離れていれば吸着しない
assert(snap(Vec2(x: 7, y: 3), guides: guideList,
thresholdMillimeters: 0.5) == nil)
ガイド交点も候補にしたい場合は、交点と各ガイドへの垂足を同じ距離尺度で比較します。候補が同距離のときの優先順位は、ガイドIDなど安定したキーで決めておくと結果がぶれません。複数選択では各図形を独立に補正せず、選択全体の枠の候補点から共通移動量を1つ決めると配置が崩れません。
ドラッグ中の「見た目」と確定データを分ける
指を動かすたびに文書の正本へ保存し、毎回Undoを積むと、1回のドラッグを戻すのに何十回もUndoが必要になります。さらに、ジェスチャーが途中で中断された際の復旧も複雑になります。
SwiftUIでは@GestureStateを使うと、ジェスチャー中だけの一時値を保持でき、終了・キャンセル時に初期値へ戻ります。AppleのonEndedは正常終了時にだけ実行されるため、キャンセルの後始末をonEndedだけへ依存させないようにします。
import SwiftUI
struct DragPreview: View {
@GestureState private var translation: CGSize = .zero
let onCommit: (CGSize) -> Void
var body: some View {
Circle()
.frame(width: 40, height: 40)
// 仮表示だけを変える。文書にはまだ書き込まない
.offset(translation)
.gesture(
DragGesture()
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
onCommit(value.translation) // 操作1回につき1回だけ確定
}
)
}
}
実装時にはonCommitで画面ptの移動量を文書座標へ戻し、吸着候補を計算した後、**開始時の文書revision(更新世代)**が変わっていないか確認してから確定します。途中で別文書を開いた場合や、古いコールバックが遅れて届いた場合は採用しません。@GestureStateが消えるだけで、文書更新と同じ意味になるわけではありません。
Undo(元に戻す)も確定時に1操作として登録します。AppleのUndoManagerには操作の登録やグルーピングが用意されていますが、実装では**「どの状態を戻すか」「どこで一操作が終わるか」**を先に決めると整理しやすくなります。数値入力のように厳密な位置を指定する操作では、ドラッグ向けの自動吸着を適用しないのも重要な使い分けです。
表示用ガイドの変更で、生成結果まで作り直さない
刺しゅうソフトの例では、文書には少なくとも「編集する図案」「配置ガイド」「ミシン用の針列」という異なる情報があります。
- 編集正本:図案の形状・配置など、後から編集する基礎データ
- 配置補助メタデータ:グリッド間隔やガイドの角度・表示・ロック
- 派生データ:正本から生成した、ミシンが読む針列
ガイドを回転するだけなのに針列の再生成を走らせると、編集処理が重くなり、Undo時の挙動も複雑になります。ガイドだけの変更と、図案そのものの変更を見分け、必要な派生データだけ更新する設計が有効です。一方で文書保存や画面表示の更新は必要なので、「針再生成を省略する」ことと「変更がなかったことにする」ことを混同しません。
チェックしておきたい境界条件
テストは正常なドラッグだけでなく、nilと距離ゼロの違い、無効な方向ベクトル、ズームしても一定の画面距離で吸着すること、複数選択で共通の移動量が適用されること、Undoがドラッグ1回にまとまること、キャンセルや文書切替後に古い結果が反映されないことを確認します。
さらにUIテストでは、SwiftUIのリストに表示されたコントロールが見えているように見えても、タップ可能な領域に入っていない場合があります。スクロールして対象全体を表示したうえで操作し、テスト実行件数が0件のまま「成功」と扱わない確認も必要です。
自作プロダクトでの実践とリリース状況
この考え方は、開発中のiPad刺しゅう編集アプリStitch Atelierのガイド配置機能で使っています。2026年10月18日の開発記録では、ガイドの保存・直接移動/回転・吸着・Undo/Redoを実装し、App 413件・Core 609件の自動テスト、対象のSimulator UIテスト、署名なしReleaseビルドが成功しています。
ただし、アプリ自体は未リリースです。点編集やPencil描画への吸着、実機Pencil操作、ミシンによる実際の試縫いは未検証・未完了です。これらを動作保証済みとは扱いません。
機能は特定の製品専用に見えても、今回の「画面とモデルの単位を分ける」「完全一致を失わない」「一時操作と正本更新を分ける」「派生処理を必要なときだけ再計算する」は、図形エディター、スケジュールのドラッグ操作、地図上のマーカー移動にも応用できます。
参考資料
- Apple:DragGesture.Value(translationなど)
- Apple:GestureState
- Apple:Gesture.onEnded(_:)
- Apple:UndoManager
- 自作プロダクトの実装記録:
products/stitch-atelier/updates/2026-10-18-layout-guides.md(公開可能な開発情報、未リリース)