mcpField
A
axiom-swiftui-gestures

axiom-swiftui-gestures

github.com/charleswiltgen開発1.2k204 回インストール今日更新
リポジトリ

axiom-swiftui-gestures について

SwiftUI のジェスチャー認識を扱う開発者向けガイドです。ジェスチャーの組み合わせ、状態管理、アクセシビリティ、複数プラットフォームでの処理を解説し、実装と競合の診断に役立ちます。

主な機能

  1. 01タップ、ドラッグ、長押し、拡大縮小、回転
  2. 02同時、順次、排他的なジェスチャー合成
  3. 03GestureState による一時状態の管理
  4. 04カスタムジェスチャーと速度予測
  5. 05VoiceOver とキーボードによる代替操作

活用シーン

  1. 01長押し後にドラッグして並べ替える操作の実装
  2. 02写真ビューでのドラッグとピンチズームの実装
  3. 03ScrollView とのジェスチャー競合の診断
  4. 04VoiceOver ユーザー向けの代替操作の用意

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-gestures

インストール後、Claude Code で/axiom-swiftui-gesturesを入力して読み込まれたか確認します。

詳細

SwiftUI ジェスチャー

合成パターン、状態管理、アクセシビリティとの統合を含む、SwiftUI のジェスチャー認識に関する包括的なガイドです。

このスキルを使う場面

  • タップ、ドラッグ、長押し、拡大縮小、回転のジェスチャーを実装するとき
  • 複数のジェスチャーを合成するとき(同時、順番、排他的)
  • GestureState を使ってジェスチャーの状態を管理するとき
  • カスタムジェスチャー認識機能を作成するとき
  • ジェスチャーの競合や反応しないジェスチャーをデバッグするとき
  • VoiceOver で利用できるジェスチャーにするとき
  • クロスプラットフォームのジェスチャー処理を行うとき(iOS、macOS、axiom-visionOS)

プロンプトの例

このスキルが回答するように設計されている、開発者から寄せられる実際の質問です。

1. 「ドラッグジェスチャーが動きません。ドラッグしてもビューが動きません。どうデバッグすればよいですか?」

→ このスキルでは、DragGesture の状態管理パターンを解説し、@GestureState でビューのオフセットを正しく更新する方法を示します。

2. 「同じビューにタップジェスチャーとドラッグジェスチャーを両方設定しています。タップは動きますが、ドラッグは動きません。どう直せばよいですか?」

→ このスキルでは、ジェスチャーの競合を解決するために、.simultaneously、.sequenced、.exclusively を使ってジェスチャーを合成する方法を紹介します。

3. 「アイテムをドラッグする前に、ユーザーに長押ししてもらいたいです。ジェスチャーをつなげるにはどうすればよいですか?」

→ このスキルでは、LongPressGesture と DragGesture を正しい順序で組み合わせる .sequenced パターンを紹介します。

4. 「ジェスチャーが終わってもジェスチャーの状態がリセットされません。ビューが間違った位置に残ります。」

→ このスキルでは、適切な状態管理のために、@GestureState の自動リセット動作と updating パラメーターを解説します。

5. 「VoiceOver ユーザーが、ジェスチャーを必要とする機能を利用できません。ジェスチャーを使いやすくするにはどうすればよいですか?」

→ このスキルでは、.accessibilityAction パターンと、VoiceOver ユーザー向けの代替操作を紹介します。


適切なジェスチャーの選び方(判断ツリー)

What interaction do you need?

├─ Single tap/click?
│  └─ Use Button (preferred) or TapGesture
│
├─ Drag/pan movement?
│  └─ Use DragGesture
│
├─ Hold before action?
│  └─ Use LongPressGesture
│
├─ Pinch to zoom?
│  └─ Use MagnificationGesture
│
├─ Two-finger rotation?
│  └─ Use RotationGesture
│
├─ Multiple gestures together?
│  ├─ Both at same time? → .simultaneously
│  ├─ One after another? → .sequenced
│  └─ One OR the other? → .exclusively
│
└─ Complex custom behavior?
   └─ Create custom Gesture conforming to Gesture protocol

パターン 1:基本的なジェスチャー認識

TapGesture
❌ 間違い(セマンティックでないビューにカスタムタップを設定する)
Text("Submit")
  .onTapGesture {
    submitForm()
  }

問題点:

  • VoiceOver でボタンとして読み上げられません
  • 押下時の視覚的なフィードバックがありません
  • アクセシビリティ設定に準拠しません
✅ 正しい(タップ操作には Button を使う)
Button("Submit") {
  submitForm()
}
.buttonStyle(.bordered)

TapGesture を使う場面:タップの データ(位置、回数)が必要な場合、または標準的でないタップ動作が必要な場合に限ります。

Image("map")
  .onTapGesture(count: 2) { // Double-tap for details
    showDetails()
  }
  .onTapGesture { location in // Single tap to pin
    addPin(at: location)
  }

DragGesture
❌ 間違い(ジェスチャー内で直接状態を変更する)
@State private var offset = CGSize.zero

var body: some View {
  Circle()
    .offset(offset)
    .gesture(
      DragGesture()
        .onChanged { value in
          offset = value.translation // ❌ Updates every frame, causes jank
        }
    )
}

問題点:

  • ドラッグイベントごと(1 秒あたり 60~120 回)にビューが更新されます
  • 元の位置に戻す方法がありません
  • ドラッグがキャンセルされると、中間状態が失われます
✅ 正しい(一時的な状態には GestureState を使う)
@GestureState private var dragOffset = CGSize.zero
@State private var position = CGSize.zero

var body: some View {
  Circle()
    .offset(x: position.width + dragOffset.width,
            y: position.height + dragOffset.height)
    .gesture(
      DragGesture()
        .updating($dragOffset) { value, state, _ in
          state = value.translation // Temporary during drag
        }
        .onEnded { value in
          position.width += value.translation.width // Commit final
          position.height += value.translation.height
        }
    )
}

理由:GestureState はジェスチャー終了時に自動で初期値へリセットされるため、状態の破損を防げます。


LongPressGesture
@GestureState private var isDetectingLongPress = false
@State private var completedLongPress = false

var body: some View {
  Text("Press and hold")
    .foregroundStyle(isDetectingLongPress ? .red : .blue)
    .gesture(
      LongPressGesture(minimumDuration: 1.0)
        .updating($isDetectingLongPress) { currentState, gestureState, _ in
          gestureState = currentState // Visual feedback during press
        }
        .onEnded { _ in
          completedLongPress = true // Action after hold
        }
    )
}

主なパラメーター:

  • minimumDuration:長押しする時間(デフォルトは 0.5 秒)
  • maximumDistance:キャンセルされるまでに指を動かせる距離(デフォルトは 10 ポイント)

MagnificationGesture
@GestureState private var magnificationAmount = 1.0
@State private var currentZoom = 1.0

var body: some View {
  Image("photo")
    .scaleEffect(currentZoom * magnificationAmount)
    .gesture(
      MagnificationGesture()
        .updating($magnificationAmount) { value, state, _ in
          state = value.magnification
        }
        .onEnded { value in
          currentZoom *= value.magnification
        }
    )
}

プラットフォームに関する注意事項:

  • iOS:2 本指でのピンチジェスチャー
  • macOS:トラックパッドでのピンチ
  • visionOS:3D 空間でのピンチジェスチャー

RotationGesture
@GestureState private var rotationAngle = Angle.zero
@State private var currentRotation = Angle.zero

var body: some View {
  Rectangle()
    .fill(.blue)
    .frame(width: 200, height: 200)
    .rotationEffect(currentRotation + rotationAngle)
    .gesture(
      RotationGesture()
        .updating($rotationAngle) { value, state, _ in
          state = value.rotation
        }
        .onEnded { value in
          currentRotation += value.rotation
        }
    )
}

パターン 2:ジェスチャーの合成

同時ジェスチャー
使用する場面:2 つのジェスチャーを 同時に 機能させる場合
@GestureState private var dragOffset = CGSize.zero
@GestureState private var magnificationAmount = 1.0

var body: some View {
  Image("photo")
    .offset(dragOffset)
    .scaleEffect(magnificationAmount)
    .gesture(
      DragGesture()
        .updating($dragOffset) { value, state, _ in
          state = value.translation
        }
        .simultaneously(with:
          MagnificationGesture()
            .updating($magnificationAmount) { value, state, _ in
              state = value.magnification
            }
        )
    )
}

使用例:ドラッグとピンチによるズームを同時に行える写真ビューアーです。


順番に実行するジェスチャー
使用する場面:次のジェスチャーを始める前に、1 つ目のジェスチャーを 完了 させる必要がある場合
@State private var isLongPressing = false
@GestureState private var dragOffset = CGSize.zero

var body: some View {
  Circle()
    .offset(dragOffset)
    .gesture(
      LongPressGesture(minimumDuration: 0.5)
        .onEnded { _ in
          isLongPressing = true
        }
        .sequenced(before:
          DragGesture()
            .updating($dragOffset) { value, state, _ in
              state = value.translation
            }
            .onEnded { _ in
              isLongPressing = false
            }
        )
    )
}

使用例:iOS のホーム画面。長押しして編集モードに入り、その後 ドラッグして並べ替えます。


排他的なジェスチャー
使用する場面:両方ではなく、いずれか 1 つだけ のジェスチャーを優先させる場合
var body: some View {
  Rectangle()
    .gesture(
      TapGesture(count: 2) // Double-tap
        .onEnded { _ in
          zoom()
        }
        .exclusively(before:
          TapGesture(count: 1) // Single tap
            .onEnded { _ in
              select()
            }
        )
    )
}

理由:.exclusively を使わないと、ダブルタップでシングルタップとダブルタップの両方のハンドラーが実行されます。

仕組み:SwiftUI は 2 回目のタップがあるかどうかを待ちます。あれば → ダブルタップが優先されます。なければ → シングルタップが優先されます。


パターン 3:GestureState と State

それぞれの使いどころ
ユースケース状態の種類理由
ジェスチャー中の一時的なフィードバック@GestureStateジェスチャーが終了すると自動的にリセットされます
最終的に確定した値@Stateジェスチャー後も保持されます
ジェスチャー中のアニメーション@GestureState滑らかな遷移が得られます
データの永続化@Stateビューが更新されても保持されます
完全な例:ドラッグ可能なカード
struct DraggableCard: View {
  @GestureState private var dragOffset = CGSize.zero // Temporary
  @State private var position = CGSize.zero          // Permanent

  var body: some View {
    RoundedRectangle(cornerRadius: 12)
      .fill(.blue)
      .frame(width: 300, height: 200)
      .offset(
        x: position.width + dragOffset.width,
        y: position.height + dragOffset.height
      )
      .gesture(
        DragGesture()
          .updating($dragOffset) { value, state, transaction in
            state = value.translation

            // Enable animation for smooth feedback
            transaction.animation = .interactiveSpring()
          }
          .onEnded { value in
            // Commit final position with animation
            withAnimation(.spring()) {
              position.width += value.translation.width
              position.height += value.translation.height
            }
          }
      )
  }
}

重要なポイント:GestureState の第3引数 transaction を使うと、ジェスチャー中のアニメーションをカスタマイズできます。


パターン 4:カスタムジェスチャー

カスタムジェスチャーを作成する場面
  • 組み込みのジェスチャーでは実現できない動作が必要な場合
  • 複雑なジェスチャーロジックをカプセル化したい場合
  • 複数のビューでジェスチャーを再利用する場合
例:方向を判定するスワイプジェスチャー
struct SwipeGesture: Gesture {
  enum Direction {
    case left, right, up, down
  }

  let minimumDistance: CGFloat
  let coordinateSpace: CoordinateSpace

  init(minimumDistance: CGFloat = 50, coordinateSpace: CoordinateSpace = .local) {
    self.minimumDistance = minimumDistance
    self.coordinateSpace = coordinateSpace
  }

  // Value is the direction
  typealias Value = Direction

  // Body builds on DragGesture
  var body: AnyGesture<Direction> {
    DragGesture(minimumDistance: minimumDistance, coordinateSpace: coordinateSpace)
      .map { value in
        let horizontal = value.translation.width
        let vertical = value.translation.height

        if abs(horizontal) > abs(vertical) {
          return horizontal < 0 ? .left : .right
        } else {
          return vertical < 0 ? .up : .down
        }
      }
      .eraseToAnyGesture()
  }
}

// Usage
Text("Swipe me")
  .gesture(
    SwipeGesture()
      .onEnded { direction in
        switch direction {
        case .left: deleteItem()
        case .right: archiveItem()
        default: break
        }
      }
  )

パターン 5:ジェスチャーの速度と予測

速度へのアクセス
@State private var velocity: CGSize = .zero

var body: some View {
  Circle()
    .gesture(
      DragGesture()
        .onEnded { value in
          // value.velocity is deprecated in iOS 18+
          // Use value.predictedEndLocation and time

          let timeDelta = value.time.timeIntervalSince(value.startLocation.time)
          let distance = value.translation

          velocity = CGSize(
            width: distance.width / timeDelta,
            height: distance.height / timeDelta
          )

          // Animate with momentum
          withAnimation(.interpolatingSpring(stiffness: 100, damping: 15)) {
            applyMomentum(velocity: velocity)
          }
        }
    )
}
予測される終了位置(iOS 16+)
DragGesture()
  .onChanged { value in
    // Where gesture will likely end based on velocity
    let predicted = value.predictedEndLocation

    // Show preview of where item will land
    showPreview(at: predicted)
  }

ユースケース:弾むような物理挙動、慣性スクロール、投げるアニメーション。


パターン 6:アクセシビリティとの統合

カスタムジェスチャーをアクセシブルにする
❌ 誤り(ジェスチャーのみで、VoiceOver に対応していません)
Image("slider")
  .gesture(
    DragGesture()
      .onChanged { value in
        updateVolume(value.translation.width)
      }
  )

問題:VoiceOver ユーザーはスライダーを調整できません。

✅ 正しい方法(アクセシビリティアクションを追加する)
@State private var volume: Double = 50

var body: some View {
  Image("slider")
    .gesture(
      DragGesture()
        .onChanged { value in
          volume = calculateVolume(from: value.translation.width)
        }
    )
    .accessibilityElement()
    .accessibilityLabel("Volume")
    .accessibilityValue("\(Int(volume))%")
    .accessibilityAdjustableAction { direction in
      switch direction {
      case .increment:
        volume = min(100, volume + 5)
      case .decrement:
        volume = max(0, volume - 5)
      @unknown default:
        break
      }
    }
}

理由:VoiceOver ユーザーは、ジェスチャーを見たり使ったりしなくても、上下にスワイプして音量を調整できます。

キーボードでの代替操作(macOS)
Rectangle()
  .gesture(
    DragGesture()
      .onChanged { value in
        move(by: value.translation)
      }
  )
  .onKeyPress(.upArrow) {
    move(by: CGSize(width: 0, height: -10))
    return .handled
  }
  .onKeyPress(.downArrow) {
    move(by: CGSize(width: 0, height: 10))
    return .handled
  }
  .onKeyPress(.leftArrow) {
    move(by: CGSize(width: -10, height: 0))
    return .handled
  }
  .onKeyPress(.rightArrow) {
    move(by: CGSize(width: 10, height: 0))
    return .handled
  }

パターン 7:クロスプラットフォームのジェスチャー

iOS と macOS と visionOS の比較
ジェスチャーiOSmacOSvisionOS
TapGesture指でタップマウス/トラックパッドでクリック視線を向けてピンチ
DragGesture指でドラッグクリックしてドラッグピンチして移動
LongPressGesture長押しクリックして長押し長くピンチ
MagnificationGesture2本指でピンチトラックパッドでピンチ両手でピンチ
RotationGesture2本指で回転トラックパッドで回転両手で回転
プラットフォーム固有のジェスチャー
var body: some View {
  Image("photo")
    .gesture(
      #if os(iOS)
      DragGesture(minimumDistance: 10) // Smaller threshold for touch
      #elseif os(macOS)
      DragGesture(minimumDistance: 1) // Precise mouse control
      #else
      DragGesture(minimumDistance: 20) // Larger for spatial gestures
      #endif
        .onChanged { value in
          updatePosition(value.translation)
        }
    )
}

よくある落とし穴

落とし穴 1:GestureState のリセットを忘れる
❌ 誤り
@State private var offset = CGSize.zero // Should be GestureState

var body: some View {
  Circle()
    .offset(offset)
    .gesture(
      DragGesture()
        .onChanged { value in
          offset = value.translation
        }
    )
}

問題:ドラッグが終了しても、オフセットはリセットされず、最後の値のまま残ります。

修正方法:一時的な状態には @GestureState を使用するか、.onEnded 内で手動でリセットします。


落とし穴 2:ScrollView とのジェスチャー競合
❌ 誤り(ドラッグジェスチャーがスクロールを妨げます)
ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .gesture(
        DragGesture()
          .onChanged { _ in
            // Prevents scroll!
          }
      )
  }
}

修正方法:状況に応じて .highPriorityGesture() または .simultaneousGesture() を使用します。

ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .simultaneousGesture( // Allows both scroll and drag
        DragGesture()
          .onChanged { value in
            // Only trigger if horizontal swipe
            if abs(value.translation.width) > abs(value.translation.height) {
              handleSwipe(value)
            }
          }
      )
  }
}

落とし穴 3:Button の代わりに .gesture() を使う
❌ 誤り(ボタンを再実装しています)
Text("Submit")
  .padding()
  .background(.blue)
  .foregroundStyle(.white)
  .clipShape(RoundedRectangle(cornerRadius: 8))
  .onTapGesture {
    submit()
  }

問題:

  • 押下時のアニメーションがありません
  • アクセシビリティ特性がありません
  • システムのボタンスタイルが反映されません
  • コード量が増えます
✅ 正しい方法
Button("Submit") {
  submit()
}
.buttonStyle(.borderedProminent)

TapGesture を使ってもよい場合:タップ位置や複数回のタップ回数が必要な場合:

Canvas { context, size in
  // Draw canvas
}
.onTapGesture { location in
  addShape(at: location) // Need location data
}

落とし穴 4:ジェスチャーのキャンセルに対応しない
❌ 誤り(ジェスチャーが必ず完了すると想定しています)
DragGesture()
  .onChanged { value in
    showPreview(at: value.location)
  }
  .onEnded { value in
    hidePreview()
    commitChange(at: value.location)
  }

問題:ユーザーが境界の外までドラッグしてジェスチャーがキャンセルされると、プレビューが表示されたままになります。

✅ 正しい方法(GestureState は自動でリセットされます)
@GestureState private var isDragging = false

var body: some View {
  content
    .gesture(
      DragGesture()
        .updating($isDragging) { _, state, _ in
          state = true
        }
        .onChanged { value in
          if isDragging {
            showPreview(at: value.location)
          }
        }
        .onEnded { value in
          commitChange(at: value.location)
        }
    )
    .onChange(of: isDragging) { _, newValue in
      if !newValue {
        hidePreview() // Cleanup when cancelled
      }
    }
}

落とし穴 5:coordinateSpace を忘れる
❌ 誤り(位置が画面ではなくビューを基準にしています)
DragGesture()
  .onChanged { value in
    // value.location is relative to the gesture's view
    addAnnotation(at: value.location)
  }

問題:ビューがオフセットされていたりスクロールされていたりすると、座標が正しくありません。

✅ 正しい方法(座標空間を指定する)
DragGesture(coordinateSpace: .named("container"))
  .onChanged { value in
    addAnnotation(at: value.location) // Relative to "container"
  }

// In parent:
ScrollView {
  content
}
.coordinateSpace(name: "container")

選択肢:

  • .local — ジェスチャーのビューを基準とします(デフォルト)
  • .global — 画面を基準とします
  • .named("name") — 名前付き座標空間を基準とします

パフォーマンスに関する考慮事項

.onChanged 内の処理を最小限にする
❌ 遅い
DragGesture()
  .onChanged { value in
    // Called 60-120 times per second!
    let position = complexCalculation(value.translation)
    updateDatabase(position) // ❌ I/O in gesture
    reloadAllViews() // ❌ Heavy work
  }
✅ 速い
@GestureState private var dragOffset = CGSize.zero

var body: some View {
  content
    .offset(dragOffset) // Cheap - just layout
    .gesture(
      DragGesture()
        .updating($dragOffset) { value, state, _ in
          state = value.translation // Minimal work
        }
        .onEnded { value in
          // Heavy work once, not 120 times/second
          let finalPosition = complexCalculation(value.translation)
          updateDatabase(finalPosition)
        }
    )
}
Transaction を使って滑らかなアニメーションを実現する
DragGesture()
  .updating($dragOffset) { value, state, transaction in
    state = value.translation

    // Disable implicit animations during drag
    transaction.animation = nil
  }
  .onEnded { value in
    // Enable spring animation for final position
    withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
      commitPosition(value.translation)
    }
  }

理由:ジェスチャー中のアニメーションは、動作が鈍く感じられることがあります。ドラッグ中は無効にし、最後に位置を合わせるときに有効にします。


トラブルシューティング

ジェスチャーが認識されない

確認事項:

  1. ビューはインタラクティブですか?(Text などのビューは、ラップしないとジェスチャーを無視することがあります)
  2. 別のジェスチャーが優先されていませんか?(.highPriorityGesture() または .simultaneousGesture() を使用します)
  3. ビューはクリップされていますか?(.contentShape() を使用してタップ領域を定義します)
  4. ジェスチャーの条件が厳しすぎませんか?(minimumDistance、minimumDuration を確認します)
// Fix unresponsive gesture
Text("Tap me")
  .frame(width: 100, height: 100)
  .contentShape(Rectangle()) // Define full tap area
  .onTapGesture {
    handleTap()
  }
ジェスチャーがナビゲーションと競合する
NavigationLink(destination: DetailView()) {
  ItemRow(item)
    .simultaneousGesture( // Don't block navigation
      LongPressGesture()
        .onEnded { _ in
          showContextMenu()
        }
    )
}
ジェスチャーが ScrollView を妨げる

水平方向のみのジェスチャー検出を使用します:

ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .simultaneousGesture(
        DragGesture()
          .onEnded { value in
            // Only trigger on horizontal swipe
            if abs(value.translation.width) > abs(value.translation.height) * 2 {
              if value.translation.width < 0 {
                deleteItem(item)
              }
            }
          }
      )
  }
}

ジェスチャーのテスト

ジェスチャーを使った UI テスト
func testDragGesture() throws {
  let app = XCUIApplication()
  app.launch()

  let element = app.otherElements["draggable"]

  // Get start and end coordinates
  let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
  let finish = element.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))

  // Perform drag
  start.press(forDuration: 0.1, thenDragTo: finish)

  // Verify result
  XCTAssertTrue(app.staticTexts["Dragged"].exists)
}
手動テストのチェックリスト
  • ジェスチャーが最初の操作から動作する(「ウォームアップ」が不要)
  • ジェスチャーをキャンセルできる(境界の外にドラッグ)
  • 複数の素早いジェスチャーが正しく動作する
  • VoiceOver を有効にしてもジェスチャーが動作する
  • 対象のすべてのプラットフォーム(iOS/macOS/visionOS)でジェスチャーが動作する
  • ジェスチャーがスクロールやナビゲーションを妨げない
  • 操作中にジェスチャーが視覚的なフィードバックを提供する
  • ジェスチャーがアクセシビリティ設定(視差効果を減らす)に従う

リソース

WWDC:2019-237、2020-10043、2021-10018

ドキュメント:/swiftui/composing-swiftui-gestures、/swiftui/gesturestate、/swiftui/gesture

スキル:axiom-accessibility-diag、axiom-swiftui-performance、axiom-ui-testing


覚えておいてください:可能な限り、カスタムジェスチャーよりも標準のコントロール(Button、Slider)を優先します。ジェスチャーは操作性を高めるものであり、標準のコントロールに取って代わるものではありません。

コメント