mcpField
S
swiftui-gestures

swiftui-gestures

github.com/dpearson2699開発1.2k4.1k 回インストール13 週間前に更新
リポジトリ

swiftui-gestures について

swiftui-gesturesは、SwiftUIのジェスチャー操作を実装する開発者向けスキルです。認識、合成、状態管理、競合の解決、ジェスチャー固有のアクセシビリティ代替手段を扱います。

主な機能

  1. 01タップ、長押し、ドラッグへの対応
  2. 02拡大・回転ジェスチャーへの対応
  3. 03複数ジェスチャーの合成と調整
  4. 04一時状態と保持状態の管理
  5. 05ビュー階層での優先順位設定
  6. 06ジェスチャーのアクセシビリティ代替案

活用シーン

  1. 01画像へのピンチズームと回転の追加
  2. 02長押し後にドラッグする操作の構築
  3. 03親子ビュー間のジェスチャー競合の解消
  4. 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離散VoidiOS 13
LongPressGesture離散BooliOS 13
DragGesture連続DragGesture.ValueiOS 13
MagnifyGesture連続MagnifyGesture.ValueiOS 17
RotateGesture連続RotateGesture.ValueiOS 17
SpatialTapGesture離散SpatialTapGesture.ValueiOS 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 を使用する

参考資料

コメント