axiom-swiftui-gestures

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SwiftUI Gestures

SwiftUI 手势

Comprehensive guide to SwiftUI gesture recognition with composition patterns, state management, and accessibility integration.
这是一份关于SwiftUI手势识别的完整指南,包含组合模式、状态管理和无障碍访问集成。

When to Use This Skill

何时使用本技能

  • Implementing tap, drag, long press, magnification, or rotation gestures
  • Composing multiple gestures (simultaneously, sequenced, exclusively)
  • Managing gesture state with GestureState
  • Creating custom gesture recognizers
  • Debugging gesture conflicts or unresponsive gestures
  • Making gestures accessible with VoiceOver
  • Cross-platform gesture handling (iOS, macOS, axiom-visionOS)
  • 实现点击、拖拽、长按、缩放或旋转手势
  • 组合多个手势(同时触发、按顺序触发、互斥触发)
  • 使用GestureState管理手势状态
  • 创建自定义手势识别器
  • 调试手势冲突或无响应的问题
  • 为VoiceOver用户优化手势的无障碍访问性
  • 跨平台手势处理(iOS、macOS、visionOS)

Example Prompts

示例问题

These are real questions developers ask that this skill is designed to answer:
以下是开发者常问的、本技能可以解答的问题:

1. "My drag gesture isn't working - the view doesn't move when I drag it. How do I debug this?"

1. "我的拖拽手势不生效——拖拽时视图不会移动。该如何调试?"

→ The skill covers DragGesture state management patterns and shows how to properly update view offset with @GestureState
→ 本技能涵盖DragGesture的状态管理模式,展示如何通过@GestureState正确更新视图偏移量

2. "I have both a tap gesture and a drag gesture on the same view. The tap works but the drag doesn't. How do I fix this?"

2. "同一个视图上同时添加了点击和拖拽手势,点击有效但拖拽无效。该如何修复?"

→ The skill demonstrates gesture composition with .simultaneously, .sequenced, and .exclusively to resolve gesture conflicts
→ 本技能演示如何使用.simultaneously、.sequenced和.exclusively组合手势来解决冲突

3. "I want users to long press before they can drag an item. How do I chain gestures together?"

3. "我希望用户先长按才能拖拽项目。该如何将手势链式组合?"

→ The skill shows the .sequenced pattern for combining LongPressGesture with DragGesture in the correct order
→ 本技能展示了使用.sequenced模式按正确顺序组合LongPressGesture和DragGesture的方法

4. "My gesture state isn't resetting when the gesture ends. The view stays in the wrong position."

4. "手势结束后状态没有重置,视图停留在错误位置。"

→ The skill covers @GestureState automatic reset behavior and the updating parameter for proper state management
→ 本技能讲解@GestureState的自动重置行为,以及使用updating参数进行正确状态管理的方法

5. "VoiceOver users can't access features that require gestures. How do I make gestures accessible?"

5. "VoiceOver用户无法访问需要手势操作的功能。该如何让手势支持无障碍访问?"

→ The skill demonstrates .accessibilityAction patterns and providing alternative interactions for VoiceOver users

→ 本技能演示了.accessibilityAction模式,以及为VoiceOver用户提供替代交互方式的方法

Choosing the Right Gesture (Decision Tree)

选择合适的手势(决策树)

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

你需要哪种交互?

├─ 单次点击/单击?
│  └─ 使用Button(优先选择)或TapGesture
├─ 拖拽/平移?
│  └─ 使用DragGesture
├─ 按住后触发操作?
│  └─ 使用LongPressGesture
├─ 捏合缩放?
│  └─ 使用MagnificationGesture
├─ 双指旋转?
│  └─ 使用RotationGesture
├─ 同时使用多个手势?
│  ├─ 同时生效? → .simultaneously
│  ├─ 按顺序生效? → .sequenced
│  └─ 二选一生效? → .exclusively
└─ 复杂的自定义行为?
   └─ 创建符合Gesture协议的自定义Gesture

Pattern 1: Basic Gesture Recognition

模式1:基础手势识别

TapGesture

TapGesture

❌ WRONG (Custom tap on non-semantic view)

❌ 错误示例(在非语义视图上自定义点击)

swift
Text("Submit")
  .onTapGesture {
    submitForm()
  }
Problems:
  • Not announced as button to VoiceOver
  • No visual press feedback
  • Doesn't respect accessibility settings
swift
Text("Submit")
  .onTapGesture {
    submitForm()
  }
问题:
  • VoiceOver不会将其识别为按钮
  • 没有视觉按压反馈
  • 不遵循无障碍访问设置

✅ CORRECT (Use Button for tap actions)

✅ 正确示例(使用Button实现点击操作)

swift
Button("Submit") {
  submitForm()
}
.buttonStyle(.bordered)
When to use TapGesture: Only when you need tap data (location, count) or non-standard tap behavior:
swift
Image("map")
  .onTapGesture(count: 2) { // Double-tap for details
    showDetails()
  }
  .onTapGesture { location in // Single tap to pin
    addPin(at: location)
  }

swift
Button("Submit") {
  submitForm()
}
.buttonStyle(.bordered)
何时使用TapGesture:仅当你需要点击数据(位置、次数)或非标准点击行为时:
swift
Image("map")
  .onTapGesture(count: 2) { // 双击查看详情
    showDetails()
  }
  .onTapGesture { location in // 单击添加标记
    addPin(at: location)
  }

DragGesture

DragGesture

❌ WRONG (Direct state mutation in gesture)

❌ 错误示例(在手势中直接修改状态)

swift
@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
        }
    )
}
Problems:
  • View updates on every drag event (60-120 times per second)
  • No way to reset to original position
  • Loses intermediate state if drag cancelled
swift
@State private var offset = CGSize.zero

var body: some View {
  Circle()
    .offset(offset)
    .gesture(
      DragGesture()
        .onChanged { value in
          offset = value.translation // ❌ 每帧更新,导致卡顿
        }
    )
}
问题:
  • 视图在每次拖拽事件时更新(每秒60-120次)
  • 无法重置到原始位置
  • 如果拖拽被取消,会丢失中间状态

✅ CORRECT (Use GestureState for temporary state)

✅ 正确示例(使用GestureState存储临时状态)

swift
@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
        }
    )
}
Why: GestureState automatically resets to initial value when gesture ends, preventing state corruption.

swift
@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 // 拖拽过程中的临时状态
        }
        .onEnded { value in
          position.width += value.translation.width // 提交最终状态
          position.height += value.translation.height
        }
    )
}
原因:GestureState会在手势结束时自动重置为初始值,避免状态异常。

LongPressGesture

LongPressGesture

swift
@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
        }
    )
}
Key parameters:
  • minimumDuration
    : How long to hold (default 0.5 seconds)
  • maximumDistance
    : How far finger can move before cancelling (default 10 points)

swift
@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 // 按压过程中的视觉反馈
        }
        .onEnded { _ in
          completedLongPress = true // 长按完成后的操作
        }
    )
}
关键参数:
  • minimumDuration
    : 长按所需的最短时间(默认0.5秒)
  • maximumDistance
    : 手指移动超过该距离时取消手势(默认10点)

MagnificationGesture

MagnificationGesture

swift
@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
        }
    )
}
Platform notes:
  • iOS: Pinch gesture with two fingers
  • macOS: Trackpad pinch
  • visionOS: Pinch gesture in 3D space

swift
@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: 双指捏合手势
  • macOS: 触控板捏合
  • visionOS: 3D空间中的捏合手势

RotationGesture

RotationGesture

swift
@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
        }
    )
}

swift
@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
        }
    )
}

Pattern 2: Gesture Composition

模式2:手势组合

Simultaneous Gestures

同时触发的手势

Use when: Two gestures should work at the same time

适用场景:两个手势需要同时生效

swift
@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
            }
        )
    )
}
Use case: Photo viewer where you can drag AND pinch-zoom at the same time.

swift
@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
            }
        )
    )
}
使用案例:照片查看器,支持同时拖拽和捏合缩放。

Sequenced Gestures

按顺序触发的手势

Use when: One gesture must complete before the next starts

适用场景:必须先完成一个手势,才能触发下一个

swift
@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
            }
        )
    )
}
Use case: iOS Home Screen — long press to enter edit mode, then drag to reorder.

swift
@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主屏幕——长按进入编辑模式,然后拖拽重新排列图标。

Exclusive Gestures

互斥手势

Use when: Only one gesture should win, not both

适用场景:只有一个手势可以生效,不能同时触发

swift
var body: some View {
  Rectangle()
    .gesture(
      TapGesture(count: 2) // Double-tap
        .onEnded { _ in
          zoom()
        }
        .exclusively(before:
          TapGesture(count: 1) // Single tap
            .onEnded { _ in
              select()
            }
        )
    )
}
Why: Without
.exclusively
, double-tap triggers both single and double tap handlers.
How it works: SwiftUI waits to see if second tap comes. If yes → double tap wins. If no → single tap wins.

swift
var body: some View {
  Rectangle()
    .gesture(
      TapGesture(count: 2) // 双击
        .onEnded { _ in
          zoom()
        }
        .exclusively(before:
          TapGesture(count: 1) // 单击
            .onEnded { _ in
              select()
            }
        )
    )
}
原因:如果不使用.exclusively,双击会同时触发单击和双击的处理函数。
工作原理:SwiftUI会等待确认是否有第二次点击。如果有→双击生效;如果没有→单击生效。

Pattern 3: GestureState vs State

模式3:GestureState vs State

When to Use Each

适用场景对比

Use CaseState TypeWhy
Temporary feedback during gesture
@GestureState
Auto-resets when gesture ends
Final committed value
@State
Persists after gesture
Animation during gesture
@GestureState
Smooth transitions
Data persistence
@State
Survives view updates
使用场景状态类型原因
手势过程中的临时反馈
@GestureState
手势结束时自动重置
最终提交的持久值
@State
手势结束后仍保留
手势过程中的动画
@GestureState
过渡更流畅
数据持久化
@State
视图更新后仍保留

Full Example: Draggable Card

完整示例:可拖拽卡片

swift
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
            }
          }
      )
  }
}
Key insight: GestureState's third parameter
transaction
lets you customize animation during the gesture.

swift
struct DraggableCard: View {
  @GestureState private var dragOffset = CGSize.zero // 临时状态
  @State private var position = CGSize.zero          // 持久状态

  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

            // 启用动画实现流畅反馈
            transaction.animation = .interactiveSpring()
          }
          .onEnded { value in
            // 带动画提交最终位置
            withAnimation(.spring()) {
              position.width += value.translation.width
              position.height += value.translation.height
            }
          }
      )
  }
}
核心要点:GestureState的第三个参数transaction允许你自定义手势过程中的动画。

Pattern 4: Custom Gestures

模式4:自定义手势

When to Create Custom Gestures

何时创建自定义手势

  • Need gesture behavior not provided by built-in gestures
  • Want to encapsulate complex gesture logic
  • Reusing gesture across multiple views
  • 需要内置手势不支持的行为
  • 希望封装复杂的手势逻辑
  • 在多个视图中复用同一手势

Example: Swipe Gesture with Direction

示例:带方向的滑动手势

swift
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
        }
      }
  )

swift
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
  }

  // 值为滑动方向
  typealias Value = Direction

  // 基于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()
  }
}

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

Pattern 5: Gesture Velocity and Prediction

模式5:手势速度与预测

Accessing Velocity

获取速度

swift
@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)
          }
        }
    )
}
swift
@State private var velocity: CGSize = .zero

var body: some View {
  Circle()
    .gesture(
      DragGesture()
        .onEnded { value in
          // iOS 18+中value.velocity已废弃
          // 使用value.predictedEndLocation和时间计算

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

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

          // 带动量的动画
          withAnimation(.interpolatingSpring(stiffness: 100, damping: 15)) {
            applyMomentum(velocity: velocity)
          }
        }
    )
}

Predicted End Location (iOS 16+)

预测结束位置(iOS 16+)

swift
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)
  }
Use case: Springy physics, momentum scrolling, throw animations.

swift
DragGesture()
  .onChanged { value in
    // 根据速度预测手势的最终结束位置
    let predicted = value.predictedEndLocation

    // 预览项目最终的落点
    showPreview(at: predicted)
  }
使用案例:弹性物理效果、动量滚动、抛掷动画。

Pattern 6: Accessibility Integration

模式6:无障碍访问集成

Making Custom Gestures Accessible

让自定义手势支持无障碍访问

❌ WRONG (Gesture-only, no VoiceOver support)

❌ 错误示例(仅支持手势,无VoiceOver支持)

swift
Image("slider")
  .gesture(
    DragGesture()
      .onChanged { value in
        updateVolume(value.translation.width)
      }
  )
Problem: VoiceOver users can't adjust the slider.
swift
Image("slider")
  .gesture(
    DragGesture()
      .onChanged { value in
        updateVolume(value.translation.width)
      }
  )
问题:VoiceOver用户无法调节滑块。

✅ CORRECT (Add accessibility actions)

✅ 正确示例(添加无障碍访问操作)

swift
@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
      }
    }
}
Why: VoiceOver users can now swipe up/down to adjust volume without seeing or using the gesture.
swift
@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("音量")
    .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用户可以通过上下滑动来调节音量,无需依赖手势操作。

Keyboard Alternatives (macOS)

键盘替代方案(macOS)

swift
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
  }

swift
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
  }

Pattern 7: Cross-Platform Gestures

模式7:跨平台手势

iOS vs macOS vs visionOS

iOS vs macOS vs visionOS 手势对比

GestureiOSmacOSvisionOS
TapGestureTap with fingerClick with mouse/trackpadLook + pinch
DragGestureDrag with fingerClick and dragPinch and move
LongPressGestureLong pressClick and holdLong pinch
MagnificationGestureTwo-finger pinchTrackpad pinchPinch with both hands
RotationGestureTwo-finger rotateTrackpad rotateRotate with both hands
手势iOSmacOSvisionOS
TapGesture手指点击鼠标/触控板单击注视+捏合
DragGesture手指拖拽点击并拖拽捏合并移动
LongPressGesture长按点击并按住长捏合
MagnificationGesture双指捏合触控板捏合双手捏合
RotationGesture双指旋转触控板旋转双手旋转

Platform-Specific Gestures

平台专属手势配置

swift
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)
        }
    )
}

swift
var body: some View {
  Image("photo")
    .gesture(
      #if os(iOS)
      DragGesture(minimumDistance: 10) // 触控操作的阈值更小
      #elseif os(macOS)
      DragGesture(minimumDistance: 1) // 鼠标控制更精准
      #else
      DragGesture(minimumDistance: 20) // 空间手势的阈值更大
      #endif
        .onChanged { value in
          updatePosition(value.translation)
        }
    )
}

Common Pitfalls

常见陷阱

Pitfall 1: Forgetting to Reset GestureState

陷阱1:忘记重置GestureState

❌ WRONG

❌ 错误示例

swift
@State private var offset = CGSize.zero // Should be GestureState

var body: some View {
  Circle()
    .offset(offset)
    .gesture(
      DragGesture()
        .onChanged { value in
          offset = value.translation
        }
    )
}
Problem: When drag ends, offset stays at last value instead of resetting.
Fix: Use
@GestureState
for temporary state, or manually reset in
.onEnded
.

swift
@State private var offset = CGSize.zero // 应该使用GestureState

var body: some View {
  Circle()
    .offset(offset)
    .gesture(
      DragGesture()
        .onChanged { value in
          offset = value.translation
        }
    )
}
问题:拖拽结束后,偏移量会停留在最后一个值,不会重置。
修复方案:使用
@GestureState
存储临时状态,或在
.onEnded
中手动重置。

Pitfall 2: Gesture Conflicts with ScrollView

陷阱2:手势与ScrollView冲突

❌ WRONG (Drag gesture blocks scrolling)

❌ 错误示例(拖拽手势阻止滚动)

swift
ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .gesture(
        DragGesture()
          .onChanged { _ in
            // Prevents scroll!
          }
      )
  }
}
Fix: Use
.highPriorityGesture()
or
.simultaneousGesture()
appropriately:
swift
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)
            }
          }
      )
  }
}

swift
ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .gesture(
        DragGesture()
          .onChanged { _ in
            // 阻止滚动!
          }
      )
  }
}
修复方案:合理使用
.highPriorityGesture()
.simultaneousGesture()
swift
ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .simultaneousGesture( // 允许同时滚动和拖拽
        DragGesture()
          .onChanged { value in
            // 仅在水平滑动时触发
            if abs(value.translation.width) > abs(value.translation.height) {
              handleSwipe(value)
            }
          }
      )
  }
}

Pitfall 3: Using .gesture() Instead of Button

陷阱3:使用.gesture()替代Button

❌ WRONG (Reimplementing button)

❌ 错误示例(重新实现按钮功能)

swift
Text("Submit")
  .padding()
  .background(.blue)
  .foregroundStyle(.white)
  .clipShape(RoundedRectangle(cornerRadius: 8))
  .onTapGesture {
    submit()
  }
Problems:
  • No press animation
  • No accessibility traits
  • Doesn't respect system button styling
  • More code
swift
Text("Submit")
  .padding()
  .background(.blue)
  .foregroundStyle(.white)
  .clipShape(RoundedRectangle(cornerRadius: 8))
  .onTapGesture {
    submit()
  }
问题:
  • 没有按压动画
  • 无无障碍访问特性
  • 不遵循系统按钮样式
  • 代码冗余

✅ CORRECT

✅ 正确示例

swift
Button("Submit") {
  submit()
}
.buttonStyle(.borderedProminent)
When TapGesture is OK: When you need tap location or multiple tap counts:
swift
Canvas { context, size in
  // Draw canvas
}
.onTapGesture { location in
  addShape(at: location) // Need location data
}

swift
Button("Submit") {
  submit()
}
.buttonStyle(.borderedProminent)
可以使用TapGesture的场景:当你需要点击位置或多次点击计数时:
swift
Canvas { context, size in
  // 绘制画布
}
.onTapGesture { location in
  addShape(at: location) // 需要位置数据
}

Pitfall 4: Not Handling Gesture Cancellation

陷阱4:未处理手势取消

❌ WRONG (Assumes gesture always completes)

❌ 错误示例(假设手势总会完成)

swift
DragGesture()
  .onChanged { value in
    showPreview(at: value.location)
  }
  .onEnded { value in
    hidePreview()
    commitChange(at: value.location)
  }
Problem: If user drags outside bounds and gesture cancels, preview stays visible.
swift
DragGesture()
  .onChanged { value in
    showPreview(at: value.location)
  }
  .onEnded { value in
    hidePreview()
    commitChange(at: value.location)
  }
问题:如果用户拖拽到视图边界外导致手势取消,预览会一直显示。

✅ CORRECT (GestureState auto-resets)

✅ 正确示例(GestureState自动重置)

swift
@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
      }
    }
}

swift
@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() // 手势取消时清理预览
      }
    }
}

Pitfall 5: Forgetting coordinateSpace

陷阱5:忘记指定coordinateSpace

❌ WRONG (Location relative to view, not screen)

❌ 错误示例(位置相对于视图,而非屏幕)

swift
DragGesture()
  .onChanged { value in
    // value.location is relative to the gesture's view
    addAnnotation(at: value.location)
  }
Problem: If view is offset/scrolled, coordinates are wrong.
swift
DragGesture()
  .onChanged { value in
    // value.location是相对于手势所在视图的位置
    addAnnotation(at: value.location)
  }
问题:如果视图有偏移或滚动,坐标会不准确。

✅ CORRECT (Specify coordinate space)

✅ 正确示例(指定坐标空间)

swift
DragGesture(coordinateSpace: .named("container"))
  .onChanged { value in
    addAnnotation(at: value.location) // Relative to "container"
  }

// In parent:
ScrollView {
  content
}
.coordinateSpace(name: "container")
Options:
  • .local
    — Relative to gesture's view (default)
  • .global
    — Relative to screen
  • .named("name")
    — Relative to named coordinate space

swift
DragGesture(coordinateSpace: .named("container"))
  .onChanged { value in
    addAnnotation(at: value.location) // 相对于"container"的位置
  }

// 在父视图中定义:
ScrollView {
  content
}
.coordinateSpace(name: "container")
可选值:
  • .local
    — 相对于手势所在视图(默认)
  • .global
    — 相对于屏幕
  • .named("name")
    — 相对于指定的命名坐标空间

Performance Considerations

性能优化建议

Minimize Work in .onChanged

减少.onChanged中的操作

❌ SLOW

❌ 低效示例

swift
DragGesture()
  .onChanged { value in
    // Called 60-120 times per second!
    let position = complexCalculation(value.translation)
    updateDatabase(position) // ❌ I/O in gesture
    reloadAllViews() // ❌ Heavy work
  }
swift
DragGesture()
  .onChanged { value in
    // 每秒被调用60-120次!
    let position = complexCalculation(value.translation)
    updateDatabase(position) // ❌ 在手势中执行I/O操作
    reloadAllViews() // ❌ 执行繁重操作
  }

✅ FAST

✅ 高效示例

swift
@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)
        }
    )
}
swift
@GestureState private var dragOffset = CGSize.zero

var body: some View {
  content
    .offset(dragOffset) // 轻量操作——仅更新布局
    .gesture(
      DragGesture()
        .updating($dragOffset) { value, state, _ in
          state = value.translation // 最小化操作
        }
        .onEnded { value in
          // 仅执行一次繁重操作,而非每秒120次
          let finalPosition = complexCalculation(value.translation)
          updateDatabase(finalPosition)
        }
    )
}

Use Transaction for Smooth Animations

使用Transaction实现流畅动画

swift
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)
    }
  }
Why: Animations during gesture can feel sluggish. Disable during drag, enable for final snap.

swift
DragGesture()
  .updating($dragOffset) { value, state, transaction in
    state = value.translation

    // 拖拽过程中禁用隐式动画
    transaction.animation = nil
  }
  .onEnded { value in
    // 最终位置使用弹性动画
    withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
      commitPosition(value.translation)
    }
  }
原因:拖拽过程中的动画会让操作感觉卡顿。拖拽时禁用动画,最终定位时启用动画。

Troubleshooting

故障排查

Gesture Not Recognizing

手势无法被识别

Check:
  1. Is view interactive? (Some views like
    Text
    ignore gestures unless wrapped)
  2. Is another gesture taking priority? (Use
    .highPriorityGesture()
    or
    .simultaneousGesture()
    )
  3. Is view clipped? (Use
    .contentShape()
    to define tap area)
  4. Is gesture too restrictive? (Check
    minimumDistance
    ,
    minimumDuration
    )
swift
// Fix unresponsive gesture
Text("Tap me")
  .frame(width: 100, height: 100)
  .contentShape(Rectangle()) // Define full tap area
  .onTapGesture {
    handleTap()
  }
检查项:
  1. 视图是否可交互?(部分视图如
    Text
    默认忽略手势,需要包装处理)
  2. 是否有其他手势优先级更高?(使用
    .highPriorityGesture()
    .simultaneousGesture()
    调整)
  3. 视图是否被裁剪?(使用
    .contentShape()
    定义可点击区域)
  4. 手势的限制是否过于严格?(检查
    minimumDistance
    minimumDuration
    参数)
swift
// 修复无响应的手势
Text("Tap me")
  .frame(width: 100, height: 100)
  .contentShape(Rectangle()) // 定义完整的可点击区域
  .onTapGesture {
    handleTap()
  }

Gesture Conflicts with Navigation

手势与导航冲突

swift
NavigationLink(destination: DetailView()) {
  ItemRow(item)
    .simultaneousGesture( // Don't block navigation
      LongPressGesture()
        .onEnded { _ in
          showContextMenu()
        }
    )
}
swift
NavigationLink(destination: DetailView()) {
  ItemRow(item)
    .simultaneousGesture( // 不阻止导航
      LongPressGesture()
        .onEnded { _ in
          showContextMenu()
        }
    )
}

Gesture Breaking ScrollView

手势影响ScrollView滚动

Use horizontal-only gesture detection:
swift
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)
              }
            }
          }
      )
  }
}

解决方案:仅识别水平手势:
swift
ScrollView {
  ForEach(items) { item in
    ItemView(item)
      .simultaneousGesture(
        DragGesture()
          .onEnded { value in
            // 仅在水平滑动时触发
            if abs(value.translation.width) > abs(value.translation.height) * 2 {
              if value.translation.width < 0 {
                deleteItem(item)
              }
            }
          }
      )
  }
}

Testing Gestures

手势测试

UI Testing with Gestures

UI测试中的手势操作

swift
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)
}
swift
func testDragGesture() throws {
  let app = XCUIApplication()
  app.launch()

  let element = app.otherElements["draggable"]

  // 获取起始和结束坐标
  let start = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
  let finish = element.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))

  // 执行拖拽操作
  start.press(forDuration: 0.1, thenDragTo: finish)

  // 验证结果
  XCTAssertTrue(app.staticTexts["Dragged"].exists)
}

Manual Testing Checklist

手动测试清单

  • Gesture works on first interaction (no "warmup" needed)
  • Gesture can be cancelled (drag outside bounds)
  • Multiple rapid gestures work correctly
  • Gesture works with VoiceOver enabled
  • Gesture works on all target platforms (iOS/macOS/visionOS)
  • Gesture doesn't block scrolling or navigation
  • Gesture provides visual feedback during interaction
  • Gesture respects accessibility settings (Reduce Motion)

  • 首次交互时手势即可生效(无需“预热”)
  • 手势可以被取消(拖拽到视图边界外)
  • 快速连续触发多个手势时表现正常
  • 启用VoiceOver时手势功能正常
  • 在所有目标平台(iOS/macOS/visionOS)上表现一致
  • 手势不会阻止滚动或导航
  • 手势在交互过程中提供视觉反馈
  • 手势遵循无障碍访问设置(如减少动态效果)

Resources

参考资源

WWDC: 2019-237, 2020-10043, 2021-10018
Docs: /swiftui/composing-swiftui-gestures, /swiftui/gesturestate, /swiftui/gesture
Skills: axiom-accessibility-diag, axiom-swiftui-performance, axiom-ui-testing

Remember: Prefer built-in controls (Button, Slider) over custom gestures whenever possible. Gestures should enhance interaction, not replace standard controls.
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)而非自定义手势。手势应作为交互的增强,而非替代标准控件。