mcpField
A
axiom-swiftui-gestures

axiom-swiftui-gestures

github.com/charleswiltgen开发1.2k204 次安装今天更新
代码仓库

关于 axiom-swiftui-gestures

面向 SwiftUI 开发者的手势指南,介绍手势识别、组合、状态管理、无障碍支持与跨平台处理,可用于实现交互或排查手势冲突。

主要功能

  1. 01点击、拖动、长按、缩放和旋转手势
  2. 02同时、顺序与排他手势组合
  3. 03使用 GestureState 管理临时状态
  4. 04自定义手势及速度预测
  5. 05VoiceOver 与键盘替代操作

适用场景

  1. 01实现长按后拖动排序
  2. 02让图片支持拖动和捏合缩放
  3. 03排查手势与 ScrollView 的冲突
  4. 04为 VoiceOver 用户提供手势替代操作

安装

复制下面的命令,在终端中运行。

$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. “我希望用户先长按,然后才能拖动项目。该如何将手势串联起来?”

→ 此技能介绍 .sequenced 模式,演示如何按正确顺序组合 LongPressGesture 和 DragGesture

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

问题:

  • 每次拖动事件都会更新视图(每秒 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:双指捏合手势
  • 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:手势组合

同时执行手势
适用场景:两个手势应同时生效
@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
            }
        )
    )
}

用例:照片查看器,用户可以同时拖动和捏合缩放。


按顺序执行手势
适用场景:一个手势必须完成后,另一个才能开始
@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 主屏幕——长按进入编辑模式,然后拖动以重新排序。


互斥手势
适用场景:只能有一个手势生效,不能同时生效
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 会等待,判断是否会出现第二次轻点。如果会 → 双击生效;如果不会 → 单击生效。


模式 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 的第三个参数 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长按点击并按住长时间捏合
MagnificationGesture双指捏合触控板捏合双手捏合
RotationGesture双指旋转触控板旋转双手旋转
平台专属手势
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:使用 .gesture() 而不是 Button
❌ 错误(重新实现按钮)
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),而不是自定义手势。手势应增强交互体验,而非取代标准控件。

评论

支持 Markdown