swiftui-gestures について
swiftui-gesturesは、SwiftUIのジェスチャー操作を実装する開発者向けスキルです。認識、合成、状態管理、競合の解決、ジェスチャー固有のアクセシビリティ代替手段を扱います。
主な機能
- 01タップ、長押し、ドラッグへの対応
- 02拡大・回転ジェスチャーへの対応
- 03複数ジェスチャーの合成と調整
- 04一時状態と保持状態の管理
- 05ビュー階層での優先順位設定
- 06ジェスチャーのアクセシビリティ代替案
活用シーン
- 01画像へのピンチズームと回転の追加
- 02長押し後にドラッグする操作の構築
- 03親子ビュー間のジェスチャー競合の解消
- 04ジェスチャー操作のアクセシビリティ確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill swiftui-gesturesインストール後、Claude Code で/swiftui-gesturesを入力して読み込まれたか確認します。
同梱ファイルevals/evals.jsonreferences/gesture-patterns.md
詳細
SwiftUI ジェスチャ(iOS 26+)
SwiftUI のジェスチャ操作をレビューし、作成し、修正します。Swift 6.3 のパターンを使って、最新のジェスチャ API を適切に組み合わせ、状態を管理し、競合を解決します。
対象範囲: このスキルの対象は、SwiftUI のジェスチャ認識、組み合わせ、ジェスチャの状態、ジェスチャ固有のアクセシビリティ代替手段です。SwiftUI のより広範なアーキテクチャと状態の所有権は swiftui-patterns、リスト、スクロール、フォーム、コントロールのレイアウトは swiftui-layout-components、UIKit との広範なブリッジは swiftui-uikit-interop の対象です。
Apple API の利用可能状況、非推奨、動作に関する記述を修正する場合は、回答に該当する Sosumi または Apple 公式ドキュメントの URL を引用してください。
目次
- ジェスチャの概要
- TapGesture
- LongPressGesture
- DragGesture
- MagnifyGesture(iOS 17+)
- RotateGesture(iOS 17+)
- ジェスチャの組み合わせ
@GestureState- ビューへのジェスチャの追加
- カスタムジェスチャプロトコル
- よくある間違い
- レビュー用チェックリスト
- 参考資料
ジェスチャの概要
| ジェスチャ | 種類 | 値 | 対応開始バージョン |
|---|---|---|---|
TapGesture | 離散 | Void | iOS 13 |
LongPressGesture | 離散 | Bool | iOS 13 |
DragGesture | 連続 | DragGesture.Value | iOS 13 |
MagnifyGesture | 連続 | MagnifyGesture.Value | iOS 17 |
RotateGesture | 連続 | RotateGesture.Value | iOS 17 |
SpatialTapGesture | 離散 | SpatialTapGesture.Value | iOS 16 |
離散ジェスチャは一度だけ発生します(.onEnded)。連続ジェスチャは更新をストリーミングします(.onChanged、.onEnded、.updating)。
TapGesture
1 回以上のタップを認識します。複数回のタップには count パラメーターを使います。
// Single, double, and triple tap
TapGesture() .onEnded { tapped.toggle() }
TapGesture(count: 2) .onEnded { handleDoubleTap() }
TapGesture(count: 3) .onEnded { handleTripleTap() }
// Shorthand modifier
Text("Tap me").onTapGesture(count: 2) { handleDoubleTap() }
LongPressGesture
ユーザーが minimumDuration の時間だけ押し続けると成功します。指が maximumDistance を超えて動くと失敗します。
// Basic long press (0.5s default)
LongPressGesture()
.onEnded { _ in showMenu = true }
// Custom duration and distance tolerance
LongPressGesture(minimumDuration: 1.0, maximumDistance: 10)
.onEnded { _ in triggerHaptic() }
@GestureState と .updating() による視覚的なフィードバック:
@GestureState private var isPressing = false
Circle()
.fill(isPressing ? .red : .blue)
.scaleEffect(isPressing ? 1.2 : 1.0)
.gesture(
LongPressGesture(minimumDuration: 0.8)
.updating($isPressing) { current, state, _ in state = current }
.onEnded { _ in completedLongPress = true }
)
省略形:.onLongPressGesture(minimumDuration:perform:onPressingChanged:)。
DragGesture
指の動きを追跡します。Value は startLocation、location、translation、velocity、predictedEndTranslation を提供します。
DragGesture.Value.velocity は iOS 13 以降の DragGesture で利用できます。MagnifyGesture や RotateGesture など、iOS 17 以降のジェスチャ型と混同しないでください。
@State private var offset = CGSize.zero
RoundedRectangle(cornerRadius: 16)
.fill(.blue)
.frame(width: 100, height: 100)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in offset = value.translation }
.onEnded { _ in withAnimation(.spring) { offset = .zero } }
)
最小距離と座標空間を設定します:
DragGesture(minimumDistance: 20, coordinateSpace: .global)
MagnifyGesture(iOS 17+)
非推奨の MagnificationGesture を置き換えるジェスチャです。ピンチによるズーム倍率を追跡します。
@GestureState private var magnifyBy = 1.0
Image("photo")
.resizable().scaledToFit()
.scaleEffect(magnifyBy)
.gesture(
MagnifyGesture()
.updating($magnifyBy) { value, state, _ in
state = value.magnification
}
)
RotateGesture(iOS 17+)
RotateGesture は、新しい RotationGesture の代替です。2 本指で回転させた角度を追跡します。
@State private var angle = Angle.zero
Rectangle()
.fill(.blue).frame(width: 200, height: 200)
.rotationEffect(angle)
.gesture(
RotateGesture(minimumAngleDelta: .degrees(1))
.onChanged { value in angle = value.rotation }
)
倍率を保持して範囲内に制限する例や、回転を組み合わせた例については、 references/gesture-patterns.md を読み込んでください。
ジェスチャの組み合わせ
.simultaneously(with:) — 両方のジェスチャを同時に認識
let magnify = MagnifyGesture()
.onChanged { value in scale = value.magnification }
let rotate = RotateGesture()
.onChanged { value in angle = value.rotation }
Image("photo")
.scaleEffect(scale)
.rotationEffect(angle)
.gesture(magnify.simultaneously(with: rotate))
値は .first と .second のオプショナルを持つ SimultaneousGesture.Value です。
.sequenced(before:) — 1 つ目が成功してから 2 つ目を開始
let longPressBeforeDrag = LongPressGesture(minimumDuration: 0.5)
.sequenced(before: DragGesture())
.onEnded { value in
guard case .second(true, let drag?) = value else { return }
finalOffset.width += drag.translation.width
finalOffset.height += drag.translation.height
}
.exclusively(before:) — どちらか一方だけが成功(1 つ目が優先)
let doubleTapOrLongPress = TapGesture(count: 2)
.exclusively(before:
LongPressGesture()
)
.onEnded { result in
switch result {
case .first(_): handleDoubleTap()
case .second(_): handleLongPress()
}
}
@GestureState
@GestureState は、ジェスチャが終了すると初期値に自動的にリセットされるプロパティラッパーです。一時的なフィードバックにはこれを使い、値を保持する場合は @State を使います。
@GestureState private var dragOffset = CGSize.zero // resets to .zero
@State private var position = CGSize.zero // persists
Circle()
.offset(
x: position.width + dragOffset.width,
y: position.height + dragOffset.height
)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
position.width += value.translation.width
position.height += value.translation.height
}
)
アニメーションを使ったカスタムリセット:@GestureState(resetTransaction: Transaction(animation: .spring))
ビューへのジェスチャーの追加
ビュー階層におけるジェスチャーの優先順位は、次の3つのモディファイアで制御します。
| モディファイア | 動作 |
|---|---|
.gesture() | ビューまたはその子ビューですでに定義されているジェスチャーより優先順位が低くなります。 |
.highPriorityGesture() | 追加したジェスチャーが既存のジェスチャーより優先されます。 |
.simultaneousGesture() | 追加したジェスチャーが既存のジェスチャーと同じ優先順位で処理されます。 |
let parentTap = TapGesture().onEnded { handleParent() }
VStack {
Image(systemName: "star.fill")
.onTapGesture { handleChild() }
}
.simultaneousGesture(parentTap) // Both handlers run on child content.
デフォルトで優先順位の低い親ジェスチャーには .gesture(parentTap) を、追加する親ジェスチャーを優先させる場合は .highPriorityGesture(parentTap) を使用します。
GestureMask
.gesture(_:including:) を使用するときに、どのジェスチャーを対象にするかを制御します。
.gesture(drag, including: .gesture) // added gesture; disables subview gestures
.gesture(drag, including: .subviews) // subview gestures; disables added gesture
.gesture(drag, including: .all) // default: added + subview gestures
.gesture(drag, including: .none) // disables added + subview gestures
カスタムジェスチャープロトコル
Gesture に準拠させて、再利用可能なジェスチャーを作成します。
struct SwipeGesture: Gesture {
enum Direction { case left, right, up, down }
typealias Value = Direction
let minimumDistance: CGFloat
init(minimumDistance: CGFloat = 50) {
self.minimumDistance = minimumDistance
}
var body: AnyGesture<Direction> {
AnyGesture(
DragGesture(minimumDistance: minimumDistance)
.map { value in
let h = value.translation.width, v = value.translation.height
if abs(h) > abs(v) {
return h > 0 ? .right : .left
} else {
return v > 0 ? .down : .up
}
}
)
}
}
// Usage
Rectangle().gesture(SwipeGesture().onEnded { print("Swiped \($0)") })
使いやすい API にするには、View 拡張でラップします。
extension View {
func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View {
gesture(SwipeGesture().onEnded(action))
}
}
よくある間違い
1. 親/子ジェスチャーの優先順位を誤解する
親の .gesture() が子のジェスチャーを上書きすると考えないでください。ビューへのジェスチャーの追加で示すように、優先関係を明示的に選択します。
2. 一時的な状態に @GestureState ではなく @State を使用する
認識が終了したときにリセットする値には @GestureState を使用し、永続化する結果には @State を使用します。@GestureStateを参照してください。
3. 中間状態のフィードバックに .updating() を使用しない
// DON'T: No visual feedback during long press
LongPressGesture(minimumDuration: 2.0)
.onEnded { _ in showResult = true }
// DO: Provide feedback while pressing
@GestureState private var isPressing = false
LongPressGesture(minimumDuration: 2.0)
.updating($isPressing) { current, state, _ in
state = current
}
.onEnded { _ in showResult = true }
4. iOS 17+ で非推奨のジェスチャー型を使用する
// DON'T: Deprecated since iOS 17
MagnificationGesture() // deprecated — use MagnifyGesture()
// DO: Use newer gesture types
MagnifyGesture() // iOS 17+
RotateGesture() // iOS 17+ (newer alternative to RotationGesture)
5. onChanged で重い計算を行う
// DON'T: Expensive work called every frame (~60-120 Hz)
DragGesture()
.onChanged { value in
let result = performExpensiveHitTest(at: value.location)
let filtered = applyComplexFilter(result)
updateModel(filtered)
}
// DO: Throttle or defer expensive work
DragGesture()
.onChanged { value in
dragPosition = value.location // lightweight state update only
}
.onEnded { value in
performExpensiveHitTest(at: value.location) // once at end
}
6. Button を使うべき操作に onTapGesture を使用する
// DON'T: onTapGesture has no accessibility traits, VoiceOver role,
// Voice Control targeting, Switch Control scanning, or keyboard activation
Text("Delete")
.onTapGesture { deleteItem() }
// DO: Button provides all of these automatically
Button("Delete", role: .destructive) { deleteItem() }
// DO: For custom visuals, use ButtonStyle instead of onTapGesture
Button { toggleExpanded() } label: {
CardView()
}
.buttonStyle(.plain)
onTapGesture は、複数回タップ(count: 2+)、タップ位置に応じた動作、または適切なアクセシビリティ特性がすでに設定された非インタラクティブなコンテンツへのタップ認識の追加に限って使用します。
レビューチェックリスト
- 適切なジェスチャー型を使用していること:
MagnifyGesture/RotateGesture(非推奨のMagnification/Rotation系ではない) - リセットが必要な一時的な値には
@GestureState、永続化する値には@Stateを使用していること - 連続ジェスチャーの実行中に、
.updating()で中間的な視覚フィードバックを提供していること - 親/子の競合を
.highPriorityGesture()または.simultaneousGesture()で解決していること -
onChangedクロージャーが軽量であること — フレームごとに重い計算を行わない - 合成ジェスチャーに適切なコンビネーターを使用していること:
simultaneously、sequenced、またはexclusively - 永続化するスケール/回転を
onEndedで妥当な範囲に制限していること - カスタム
Gesture準拠型がジェスチャーボディを返していること。カスタムValueへのマッピングにはAnyGesture<Value>を使用する - ジェスチャー駆動のアニメーションに、自然な減速を実現する
.springなどを使用していること - ビュー階層の異なるレベル間でジェスチャーを組み合わせる際に、
GestureMaskを検討していること -
onTapGestureを使用するのは、count > 1、タップ位置、または座標空間が関係する場合に限ること — 通常のシングルタップ操作には代わりにButtonを使用する
参考資料
- ドラッグによる並べ替え、ピンチによるズーム、回転と拡大縮小の組み合わせ、速度/投影、シーケンスジェスチャーの状態機械、またはジェスチャー固有の UIKit 連携の例が必要な場合は、references/gesture-patterns.mdを参照してください。
- Gesture プロトコル
- TapGesture
- LongPressGesture
- DragGesture
- DragGesture.Value.velocity
- MagnifyGesture
- RotateGesture
- GestureState
- SwiftUI ジェスチャーの合成
- ジェスチャーでインタラクティブ性を追加する