swiftui-gestures

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SwiftUI Gestures (iOS 26+)

SwiftUI 手势(iOS 26+)

Review, write, and fix SwiftUI gesture interactions. Apply modern gesture APIs with correct composition, state management, and conflict resolution using Swift 6.2 patterns.
评审、编写和修复SwiftUI手势交互。使用Swift 6.2模式,通过正确的组合方式、状态管理和冲突解决方案来应用现代手势API。

Contents

目录

Gesture Overview

手势概述

GestureTypeValueSince
TapGesture
Discrete
Void
iOS 13
LongPressGesture
Discrete
Bool
iOS 13
DragGesture
Continuous
DragGesture.Value
iOS 13
MagnifyGesture
Continuous
MagnifyGesture.Value
iOS 17
RotateGesture
Continuous
RotateGesture.Value
iOS 17
SpatialTapGesture
Discrete
SpatialTapGesture.Value
iOS 16
Discrete gestures fire once (
.onEnded
). Continuous gestures stream updates (
.onChanged
,
.onEnded
,
.updating
).
手势类型起始版本
TapGesture
离散型
Void
iOS 13
LongPressGesture
离散型
Bool
iOS 13
DragGesture
连续型
DragGesture.Value
iOS 13
MagnifyGesture
连续型
MagnifyGesture.Value
iOS 17
RotateGesture
连续型
RotateGesture.Value
iOS 17
SpatialTapGesture
离散型
SpatialTapGesture.Value
iOS 16
离散型手势仅触发一次(
.onEnded
)。连续型手势会持续发送更新(
.onChanged
.onEnded
.updating
)。

TapGesture

TapGesture

Recognizes one or more taps. Use the
count
parameter for multi-tap.
swift
// Single, double, and triple tap
TapGesture()            .onEnded { tapped.toggle() }
TapGesture(count: 2)    .onEnded { handleDoubleTap() }
TapGesture(count: 3)    .onEnded { handleTripleTap() }

// Shorthand modifier
Text("Tap me").onTapGesture(count: 2) { handleDoubleTap() }
识别一次或多次轻点。使用
count
参数实现多点击。
swift
// 单点、双击和三击
TapGesture()            .onEnded { tapped.toggle() }
TapGesture(count: 2)    .onEnded { handleDoubleTap() }
TapGesture(count: 3)    .onEnded { handleTripleTap() }

// 简写修饰符
Text("Tap me").onTapGesture(count: 2) { handleDoubleTap() }

LongPressGesture

LongPressGesture

Succeeds after the user holds for
minimumDuration
. Fails if finger moves beyond
maximumDistance
.
swift
// Basic long press (0.5s default)
LongPressGesture()
    .onEnded { _ in showMenu = true }

// Custom duration and distance tolerance
LongPressGesture(minimumDuration: 1.0, maximumDistance: 10)
    .onEnded { _ in triggerHaptic() }
With visual feedback via
@GestureState
+
.updating()
:
swift
@GestureState private var isPressing = false

Circle()
    .fill(isPressing ? .red : .blue)
    .scaleEffect(isPressing ? 1.2 : 1.0)
    .gesture(
        LongPressGesture(minimumDuration: 0.8)
            .updating($isPressing) { current, state, _ in state = current }
            .onEnded { _ in completedLongPress = true }
    )
Shorthand:
.onLongPressGesture(minimumDuration:perform:onPressingChanged:)
.
用户按住达到
minimumDuration
时长后触发成功。如果手指移动超出
maximumDistance
则触发失败。
swift
// 基础长按(默认0.5秒)
LongPressGesture()
    .onEnded { _ in showMenu = true }

// 自定义时长和距离容差
LongPressGesture(minimumDuration: 1.0, maximumDistance: 10)
    .onEnded { _ in triggerHaptic() }
通过
@GestureState
+
.updating()
提供视觉反馈:
swift
@GestureState private var isPressing = false

Circle()
    .fill(isPressing ? .red : .blue)
    .scaleEffect(isPressing ? 1.2 : 1.0)
    .gesture(
        LongPressGesture(minimumDuration: 0.8)
            .updating($isPressing) { current, state, _ in state = current }
            .onEnded { _ in completedLongPress = true }
    )
简写方式:
.onLongPressGesture(minimumDuration:perform:onPressingChanged:)

DragGesture

DragGesture

Tracks finger movement.
Value
provides
startLocation
,
location
,
translation
,
velocity
, and
predictedEndTranslation
.
swift
@State private var offset = CGSize.zero

RoundedRectangle(cornerRadius: 16)
    .fill(.blue)
    .frame(width: 100, height: 100)
    .offset(offset)
    .gesture(
        DragGesture()
            .onChanged { value in offset = value.translation }
            .onEnded { _ in withAnimation(.spring) { offset = .zero } }
    )
Configure minimum distance and coordinate space:
swift
DragGesture(minimumDistance: 20, coordinateSpace: .global)
追踪手指移动。
Value
包含
startLocation
location
translation
velocity
predictedEndTranslation
swift
@State private var offset = CGSize.zero

RoundedRectangle(cornerRadius: 16)
    .fill(.blue)
    .frame(width: 100, height: 100)
    .offset(offset)
    .gesture(
        DragGesture()
            .onChanged { value in offset = value.translation }
            .onEnded { _ in withAnimation(.spring) { offset = .zero } }
    )
配置最小距离和坐标空间:
swift
DragGesture(minimumDistance: 20, coordinateSpace: .global)

MagnifyGesture (iOS 17+)

MagnifyGesture(iOS 17+)

Replaces the deprecated
MagnificationGesture
. Tracks pinch-to-zoom scale.
swift
@GestureState private var magnifyBy = 1.0

Image("photo")
    .resizable().scaledToFit()
    .scaleEffect(magnifyBy)
    .gesture(
        MagnifyGesture()
            .updating($magnifyBy) { value, state, _ in
                state = value.magnification
            }
    )
With persisted scale:
swift
@State private var currentScale = 1.0
@GestureState private var gestureScale = 1.0

Image("photo")
    .scaleEffect(currentScale * gestureScale)
    .gesture(
        MagnifyGesture(minimumScaleDelta: 0.01)
            .updating($gestureScale) { value, state, _ in state = value.magnification }
            .onEnded { value in
                currentScale = min(max(currentScale * value.magnification, 0.5), 5.0)
            }
    )
替代已弃用的
MagnificationGesture
。追踪捏合缩放比例。
swift
@GestureState private var magnifyBy = 1.0

Image("photo")
    .resizable().scaledToFit()
    .scaleEffect(magnifyBy)
    .gesture(
        MagnifyGesture()
            .updating($magnifyBy) { value, state, _ in
                state = value.magnification
            }
    )
持久化缩放比例:
swift
@State private var currentScale = 1.0
@GestureState private var gestureScale = 1.0

Image("photo")
    .scaleEffect(currentScale * gestureScale)
    .gesture(
        MagnifyGesture(minimumScaleDelta: 0.01)
            .updating($gestureScale) { value, state, _ in state = value.magnification }
            .onEnded { value in
                currentScale = min(max(currentScale * value.magnification, 0.5), 5.0)
            }
    )

RotateGesture (iOS 17+)

RotateGesture(iOS 17+)

Replaces the deprecated
RotationGesture
. Tracks two-finger rotation angle.
swift
@State private var angle = Angle.zero

Rectangle()
    .fill(.blue).frame(width: 200, height: 200)
    .rotationEffect(angle)
    .gesture(
        RotateGesture(minimumAngleDelta: .degrees(1))
            .onChanged { value in angle = value.rotation }
    )
With persisted rotation:
swift
@State private var currentAngle = Angle.zero
@GestureState private var gestureAngle = Angle.zero

Rectangle()
    .rotationEffect(currentAngle + gestureAngle)
    .gesture(
        RotateGesture()
            .updating($gestureAngle) { value, state, _ in state = value.rotation }
            .onEnded { value in currentAngle += value.rotation }
    )
替代已弃用的
RotationGesture
。追踪双指旋转角度。
swift
@State private var angle = Angle.zero

Rectangle()
    .fill(.blue).frame(width: 200, height: 200)
    .rotationEffect(angle)
    .gesture(
        RotateGesture(minimumAngleDelta: .degrees(1))
            .onChanged { value in angle = value.rotation }
    )
持久化旋转角度:
swift
@State private var currentAngle = Angle.zero
@GestureState private var gestureAngle = Angle.zero

Rectangle()
    .rotationEffect(currentAngle + gestureAngle)
    .gesture(
        RotateGesture()
            .updating($gestureAngle) { value, state, _ in state = value.rotation }
            .onEnded { value in currentAngle += value.rotation }
    )

Gesture Composition

手势组合

.simultaneously(with:)
— both gestures recognized at the same time

.simultaneously(with:)
— 同时识别两个手势

swift
let magnify = MagnifyGesture()
    .onChanged { value in scale = value.magnification }

let rotate = RotateGesture()
    .onChanged { value in angle = value.rotation }

Image("photo")
    .scaleEffect(scale)
    .rotationEffect(angle)
    .gesture(magnify.simultaneously(with: rotate))
The value is
SimultaneousGesture.Value
with
.first
and
.second
optionals.
swift
let magnify = MagnifyGesture()
    .onChanged { value in scale = value.magnification }

let rotate = RotateGesture()
    .onChanged { value in angle = value.rotation }

Image("photo")
    .scaleEffect(scale)
    .rotationEffect(angle)
    .gesture(magnify.simultaneously(with: rotate))
返回值为
SimultaneousGesture.Value
,包含
.first
.second
两个可选属性。

.sequenced(before:)
— first must succeed before second begins

.sequenced(before:)
— 第一个手势成功后才会触发第二个

swift
let longPressBeforeDrag = LongPressGesture(minimumDuration: 0.5)
    .sequenced(before: DragGesture())
    .onEnded { value in
        guard case .second(true, let drag?) = value else { return }
        finalOffset.width += drag.translation.width
        finalOffset.height += drag.translation.height
    }
swift
let longPressBeforeDrag = LongPressGesture(minimumDuration: 0.5)
    .sequenced(before: DragGesture())
    .onEnded { value in
        guard case .second(true, let drag?) = value else { return }
        finalOffset.width += drag.translation.width
        finalOffset.height += drag.translation.height
    }

.exclusively(before:)
— only one succeeds (first has priority)

.exclusively(before:)
— 仅其中一个手势触发成功(第一个拥有优先级)

swift
let doubleTapOrLongPress = TapGesture(count: 2)
    .map { ExclusiveResult.doubleTap }
    .exclusively(before:
        LongPressGesture()
            .map { _ in ExclusiveResult.longPress }
    )
    .onEnded { result in
        switch result {
        case .first(let val): handleDoubleTap()
        case .second(let val): handleLongPress()
        }
    }
swift
let doubleTapOrLongPress = TapGesture(count: 2)
    .map { ExclusiveResult.doubleTap }
    .exclusively(before:
        LongPressGesture()
            .map { _ in ExclusiveResult.longPress }
    )
    .onEnded { result in
        switch result {
        case .first(let val): handleDoubleTap()
        case .second(let val): handleLongPress()
        }
    }

@GestureState

@GestureState

@GestureState
is a property wrapper that automatically resets to its initial value when the gesture ends. Use for transient feedback; use
@State
for values that persist.
swift
@GestureState private var dragOffset = CGSize.zero  // resets to .zero
@State private var position = CGSize.zero            // persists

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
            }
    )
Custom reset with animation:
@GestureState(resetTransaction: Transaction(animation: .spring))
@GestureState
是一个属性包装器,在手势结束时会自动重置为初始值。适用于临时反馈场景;若需持久化值,请使用
@State
swift
@GestureState private var dragOffset = CGSize.zero  // 会重置为.zero
@State private var position = CGSize.zero            // 持久化保存

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(resetTransaction: Transaction(animation: .spring))

Adding Gestures to Views

为视图添加手势

Three modifiers control gesture priority in the view hierarchy:
ModifierBehavior
.gesture()
Default priority. Child gestures win over parent.
.highPriorityGesture()
Parent gesture takes precedence over child.
.simultaneousGesture()
Both parent and child gestures fire.
swift
// Problem: parent tap swallows child tap
VStack {
    Button("Child") { handleChild() }  // never fires
}
.gesture(TapGesture().onEnded { handleParent() })

// Fix 1: Use simultaneousGesture on parent
VStack {
    Button("Child") { handleChild() }
}
.simultaneousGesture(TapGesture().onEnded { handleParent() })

// Fix 2: Give parent explicit priority
VStack {
    Text("Child")
        .gesture(TapGesture().onEnded { handleChild() })
}
.highPriorityGesture(TapGesture().onEnded { handleParent() })
有三个修饰符用于控制视图层级中的手势优先级:
修饰符行为
.gesture()
默认优先级。子手势优先于父手势。
.highPriorityGesture()
父手势优先于子手势。
.simultaneousGesture()
父手势和子手势同时触发。
swift
// 问题:父级轻点手势覆盖子级轻点手势
VStack {
    Button("Child") { handleChild() }  // 永远不会触发
}
.gesture(TapGesture().onEnded { handleParent() })

// 修复方案1:在父视图上使用.simultaneousGesture()
VStack {
    Button("Child") { handleChild() }
}
.simultaneousGesture(TapGesture().onEnded { handleParent() })

// 修复方案2:为父视图设置明确优先级
VStack {
    Text("Child")
        .gesture(TapGesture().onEnded { handleChild() })
}
.highPriorityGesture(TapGesture().onEnded { handleParent() })

GestureMask

GestureMask

Control which gestures participate when using
.gesture(_:including:)
:
swift
.gesture(drag, including: .gesture)   // only this gesture, not subviews
.gesture(drag, including: .subviews)  // only subview gestures
.gesture(drag, including: .all)       // default: this + subviews
使用
.gesture(_:including:)
时,可通过GestureMask控制参与的手势:
swift
.gesture(drag, including: .gesture)   // 仅当前手势,不包含子视图手势
.gesture(drag, including: .subviews)  // 仅子视图手势
.gesture(drag, including: .all)       // 默认:当前手势 + 子视图手势

Custom Gesture Protocol

自定义Gesture协议

Create reusable gestures by conforming to
Gesture
:
swift
struct SwipeGesture: Gesture {
    enum Direction { case left, right, up, down }
    let minimumDistance: CGFloat
    let onSwipe: (Direction) -> Void

    init(minimumDistance: CGFloat = 50, onSwipe: @escaping (Direction) -> Void) {
        self.minimumDistance = minimumDistance
        self.onSwipe = onSwipe
    }

    var body: some Gesture {
        DragGesture(minimumDistance: minimumDistance)
            .onEnded { value in
                let h = value.translation.width, v = value.translation.height
                if abs(h) > abs(v) {
                    onSwipe(h > 0 ? .right : .left)
                } else {
                    onSwipe(v > 0 ? .down : .up)
                }
            }
    }
}

// Usage
Rectangle().gesture(SwipeGesture { print("Swiped \($0)") })
Wrap in a
View
extension for ergonomic API:
swift
extension View {
    func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View {
        gesture(SwipeGesture(onSwipe: action))
    }
}
通过遵循
Gesture
协议创建可复用手势:
swift
struct SwipeGesture: Gesture {
    enum Direction { case left, right, up, down }
    let minimumDistance: CGFloat
    let onSwipe: (Direction) -> Void

    init(minimumDistance: CGFloat = 50, onSwipe: @escaping (Direction) -> Void) {
        self.minimumDistance = minimumDistance
        self.onSwipe = onSwipe
    }

    var body: some Gesture {
        DragGesture(minimumDistance: minimumDistance)
            .onEnded { value in
                let h = value.translation.width, v = value.translation.height
                if abs(h) > abs(v) {
                    onSwipe(h > 0 ? .right : .left)
                } else {
                    onSwipe(v > 0 ? .down : .up)
                }
            }
    }
}

// 使用示例
Rectangle().gesture(SwipeGesture { print("Swiped \($0)") })
将其封装为
View
扩展以提升API易用性:
swift
extension View {
    func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View {
        gesture(SwipeGesture(onSwipe: action))
    }
}

Common Mistakes

常见错误

1. Conflicting parent/child gestures

1. 父子手势冲突

swift
// DON'T: Parent .gesture() conflicts with child tap
VStack {
    Button("Action") { doSomething() }
}
.gesture(TapGesture().onEnded { parentAction() })

// DO: Use .simultaneousGesture() or .highPriorityGesture()
VStack {
    Button("Action") { doSomething() }
}
.simultaneousGesture(TapGesture().onEnded { parentAction() })
swift
// 错误写法:父级.gesture()与子级轻点手势冲突
VStack {
    Button("Action") { doSomething() }
}
.gesture(TapGesture().onEnded { parentAction() })

// 正确写法:使用.simultaneousGesture()或.highPriorityGesture()
VStack {
    Button("Action") { doSomething() }
}
.simultaneousGesture(TapGesture().onEnded { parentAction() })

2. Using @State instead of @GestureState for transient state

2. 临时状态使用@State而非@GestureState

swift
// DON'T: @State doesn't auto-reset — view stays offset after gesture ends
@State private var dragOffset = CGSize.zero

DragGesture()
    .onChanged { value in dragOffset = value.translation }
    .onEnded { _ in dragOffset = .zero }  // manual reset required

// DO: @GestureState auto-resets when gesture ends
@GestureState private var dragOffset = CGSize.zero

DragGesture()
    .updating($dragOffset) { value, state, _ in
        state = value.translation
    }
swift
// 错误写法:@State不会自动重置 —— 手势结束后视图仍保持偏移
@State private var dragOffset = CGSize.zero

DragGesture()
    .onChanged { value in dragOffset = value.translation }
    .onEnded { _ in dragOffset = .zero }  // 需要手动重置

// 正确写法:@GestureState在手势结束时自动重置
@GestureState private var dragOffset = CGSize.zero

DragGesture()
    .updating($dragOffset) { value, state, _ in
        state = value.translation
    }

3. Not using .updating() for intermediate feedback

3. 未使用.updating()提供中间反馈

swift
// DON'T: No visual feedback during long press
LongPressGesture(minimumDuration: 2.0)
    .onEnded { _ in showResult = true }

// DO: Provide feedback while pressing
@GestureState private var isPressing = false

LongPressGesture(minimumDuration: 2.0)
    .updating($isPressing) { current, state, _ in
        state = current
    }
    .onEnded { _ in showResult = true }
swift
// 错误写法:长按过程中无视觉反馈
LongPressGesture(minimumDuration: 2.0)
    .onEnded { _ in showResult = true }

// 正确写法:在按压过程中提供反馈
@GestureState private var isPressing = false

LongPressGesture(minimumDuration: 2.0)
    .updating($isPressing) { current, state, _ in
        state = current
    }
    .onEnded { _ in showResult = true }

4. Using deprecated gesture types on iOS 17+

4. iOS 17+仍使用已弃用的手势类型

swift
// DON'T: Deprecated since iOS 17
MagnificationGesture()   // deprecated
RotationGesture()        // deprecated

// DO: Use modern replacements
MagnifyGesture()         // iOS 17+
RotateGesture()          // iOS 17+
swift
// 错误写法:iOS 17起已弃用
MagnificationGesture()   // 已弃用
RotationGesture()        // 已弃用

// 正确写法:使用现代替代方案
MagnifyGesture()         // iOS 17+
RotateGesture()          // iOS 17+

5. Heavy computation in onChanged

5. 在onChanged中执行大量计算

swift
// DON'T: Expensive work called every frame (~60-120 Hz)
DragGesture()
    .onChanged { value in
        let result = performExpensiveHitTest(at: value.location)
        let filtered = applyComplexFilter(result)
        updateModel(filtered)
    }

// DO: Throttle or defer expensive work
DragGesture()
    .onChanged { value in
        dragPosition = value.location  // lightweight state update only
    }
    .onEnded { value in
        performExpensiveHitTest(at: value.location)  // once at end
    }
swift
// 错误写法:每帧都会调用昂贵操作(约60-120 Hz)
DragGesture()
    .onChanged { value in
        let result = performExpensiveHitTest(at: value.location)
        let filtered = applyComplexFilter(result)
        updateModel(filtered)
    }

// 正确写法:节流或延迟执行昂贵操作
DragGesture()
    .onChanged { value in
        dragPosition = value.location  // 仅执行轻量状态更新
    }
    .onEnded { value in
        performExpensiveHitTest(at: value.location)  // 仅在结束时执行一次
    }

Review Checklist

评审检查清单

  • Correct gesture type:
    MagnifyGesture
    /
    RotateGesture
    (not deprecated
    Magnification
    /
    Rotation
    variants)
  • @GestureState
    used for transient values that should reset;
    @State
    for persisted values
  • .updating()
    provides intermediate visual feedback during continuous gestures
  • Parent/child conflicts resolved with
    .highPriorityGesture()
    or
    .simultaneousGesture()
  • onChanged
    closures are lightweight — no heavy computation every frame
  • Composed gestures use correct combinator:
    simultaneously
    ,
    sequenced
    , or
    exclusively
  • Persisted scale/rotation clamped to reasonable bounds in
    onEnded
  • Custom
    Gesture
    conformances use
    var body: some Gesture
    (not
    View
    )
  • Gesture-driven animations use
    .spring
    or similar for natural deceleration
  • GestureMask
    considered when mixing gestures across view hierarchy levels
  • 使用正确的手势类型:
    MagnifyGesture
    /
    RotateGesture
    (而非已弃用的
    Magnification
    /
    Rotation
    变体)
  • 临时值使用
    @GestureState
    (会自动重置);持久值使用
    @State
  • 连续手势通过
    .updating()
    提供中间视觉反馈
  • 通过
    .highPriorityGesture()
    .simultaneousGesture()
    解决父子手势冲突
  • onChanged
    闭包保持轻量 —— 避免每帧执行大量计算
  • 组合手势使用正确的组合器:
    simultaneously
    sequenced
    exclusively
  • 持久化的缩放/旋转比例在
    onEnded
    中限制在合理范围内
  • 自定义
    Gesture
    遵循
    var body: some Gesture
    (而非
    View
  • 手势驱动的动画使用
    .spring
    或类似动画实现自然减速
  • 在视图层级混合手势时考虑使用
    GestureMask

References

参考资料