axiom-swiftui-gestures
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSwiftUI 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协议的自定义GesturePattern 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:
- : How long to hold (default 0.5 seconds)
minimumDuration - : How far finger can move before cancelling (default 10 points)
maximumDistance
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 // 长按完成后的操作
}
)
}关键参数:
- : 长按所需的最短时间(默认0.5秒)
minimumDuration - : 手指移动超过该距离时取消手势(默认10点)
maximumDistance
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 , double-tap triggers both single and double tap handlers.
.exclusivelyHow 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 Case | State Type | Why |
|---|---|---|
| Temporary feedback during gesture | | Auto-resets when gesture ends |
| Final committed value | | Persists after gesture |
| Animation during gesture | | Smooth transitions |
| Data persistence | | Survives view updates |
| 使用场景 | 状态类型 | 原因 |
|---|---|---|
| 手势过程中的临时反馈 | | 手势结束时自动重置 |
| 最终提交的持久值 | | 手势结束后仍保留 |
| 手势过程中的动画 | | 过渡更流畅 |
| 数据持久化 | | 视图更新后仍保留 |
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 lets you customize animation during the gesture.
transactionswift
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 手势对比
| Gesture | iOS | macOS | visionOS |
|---|---|---|---|
| TapGesture | Tap with finger | Click with mouse/trackpad | Look + pinch |
| DragGesture | Drag with finger | Click and drag | Pinch and move |
| LongPressGesture | Long press | Click and hold | Long pinch |
| MagnificationGesture | Two-finger pinch | Trackpad pinch | Pinch with both hands |
| RotationGesture | Two-finger rotate | Trackpad rotate | Rotate with both hands |
| 手势 | iOS | macOS | visionOS |
|---|---|---|---|
| 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 for temporary state, or manually reset in .
@GestureState.onEndedswift
@State private var offset = CGSize.zero // 应该使用GestureState
var body: some View {
Circle()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
)
}问题:拖拽结束后,偏移量会停留在最后一个值,不会重置。
修复方案:使用存储临时状态,或在中手动重置。
@GestureState.onEndedPitfall 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 or appropriately:
.highPriorityGesture().simultaneousGesture()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:
- — Relative to gesture's view (default)
.local - — Relative to screen
.global - — Relative to named coordinate space
.named("name")
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:
- Is view interactive? (Some views like ignore gestures unless wrapped)
Text - Is another gesture taking priority? (Use or
.highPriorityGesture()).simultaneousGesture() - Is view clipped? (Use to define tap area)
.contentShape() - 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()
}检查项:
- 视图是否可交互?(部分视图如默认忽略手势,需要包装处理)
Text - 是否有其他手势优先级更高?(使用或
.highPriorityGesture()调整).simultaneousGesture() - 视图是否被裁剪?(使用定义可点击区域)
.contentShape() - 手势的限制是否过于严格?(检查、
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)而非自定义手势。手势应作为交互的增强,而非替代标准控件。