axiom-swiftui-gestures について
SwiftUI のジェスチャー認識を扱う開発者向けガイドです。ジェスチャーの組み合わせ、状態管理、アクセシビリティ、複数プラットフォームでの処理を解説し、実装と競合の診断に役立ちます。
主な機能
- 01タップ、ドラッグ、長押し、拡大縮小、回転
- 02同時、順次、排他的なジェスチャー合成
- 03GestureState による一時状態の管理
- 04カスタムジェスチャーと速度予測
- 05VoiceOver とキーボードによる代替操作
活用シーン
- 01長押し後にドラッグして並べ替える操作の実装
- 02写真ビューでのドラッグとピンチズームの実装
- 03ScrollView とのジェスチャー競合の診断
- 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 の比較
| ジェスチャー | iOS | macOS | visionOS |
|---|---|---|---|
| TapGesture | 指でタップ | マウス/トラックパッドでクリック | 視線を向けてピンチ |
| DragGesture | 指でドラッグ | クリックしてドラッグ | ピンチして移動 |
| LongPressGesture | 長押し | クリックして長押し | 長くピンチ |
| MagnificationGesture | 2本指でピンチ | トラックパッドでピンチ | 両手でピンチ |
| RotationGesture | 2本指で回転 | トラックパッドで回転 | 両手で回転 |
プラットフォーム固有のジェスチャー
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)
}
}
理由:ジェスチャー中のアニメーションは、動作が鈍く感じられることがあります。ドラッグ中は無効にし、最後に位置を合わせるときに有効にします。
トラブルシューティング
ジェスチャーが認識されない
確認事項:
- ビューはインタラクティブですか?(
Textなどのビューは、ラップしないとジェスチャーを無視することがあります) - 別のジェスチャーが優先されていませんか?(
.highPriorityGesture()または.simultaneousGesture()を使用します) - ビューはクリップされていますか?(
.contentShape()を使用してタップ領域を定義します) - ジェスチャーの条件が厳しすぎませんか?(
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)を優先します。ジェスチャーは操作性を高めるものであり、標準のコントロールに取って代わるものではありません。