mcpField
S
swiftui-gestures

swiftui-gestures

github.com/dpearson2699开发1.2k4.1k 次安装13 周前更新
代码仓库

关于 swiftui-gestures

swiftui-gestures 是面向 SwiftUI 开发者的手势交互技能,介绍识别、组合、状态管理和冲突处理,并涵盖手势相关的无障碍替代方式。

主要功能

  1. 01支持点击、长按与拖动手势
  2. 02处理缩放与旋转手势
  3. 03组合并协调多个手势
  4. 04管理临时及持久状态
  5. 05调整视图层级中的手势优先级
  6. 06提供手势无障碍建议

适用场景

  1. 01实现图片的缩放与旋转
  2. 02构建长按后拖动交互
  3. 03排查父子视图手势冲突
  4. 04检查手势操作的无障碍体验

安装

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

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-gestures

安装后在 Claude Code 中输入/swiftui-gestures确认已加载。

附带文件evals/evals.jsonreferences/gesture-patterns.md

详细介绍

SwiftUI 手势(iOS 26+)

审核、编写和修复 SwiftUI 手势交互。使用 Swift 6.3 模式,通过现代手势 API 实现正确的组合、状态管理和冲突解决。

**范围边界:**本技能负责 SwiftUI 手势识别、组合、手势状态以及手势专属的无障碍替代方案。更广泛的 SwiftUI 架构和状态所有权由 swiftui-patterns 负责;列表、滚动、表单和控件布局由 swiftui-layout-components 负责;更广泛的 UIKit 桥接由 swiftui-uikit-interop 负责。

更正 Apple API 可用性、弃用或行为相关说法时,请在回复中引用相关的 Sosumi 或 Apple 官方文档 URL。

目录

手势概览

手势类型值起始版本
TapGesture离散VoidiOS 13
LongPressGesture离散BooliOS 13
DragGesture连续DragGesture.ValueiOS 13
MagnifyGesture连续MagnifyGesture.ValueiOS 17
RotateGesture连续RotateGesture.ValueiOS 17
SpatialTapGesture离散SpatialTapGesture.ValueiOS 16

离散手势只触发一次(.onEnded)。连续手势会持续提供更新 (.onChanged、.onEnded、.updating)。

TapGesture

识别一次或多次点击。使用 count 参数设置多击次数。

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

LongPressGesture

用户按住达到 minimumDuration 后成功。如果手指移动超过 maximumDistance,则失败。

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

通过 @GestureState 和 .updating() 提供视觉反馈:

@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

跟踪手指移动。Value 提供 startLocation、location、 translation、velocity 和 predictedEndTranslation。 iOS 13+ 的 DragGesture 已提供 DragGesture.Value.velocity; 不要将它与 MagnifyGesture 和 RotateGesture 等 iOS 17+ 手势类型混淆。

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

配置最小距离和坐标空间:

DragGesture(minimumDistance: 20, coordinateSpace: .global)

MagnifyGesture(iOS 17+)

替代已弃用的 MagnificationGesture。跟踪双指捏合缩放比例。

@GestureState private var magnifyBy = 1.0

Image("photo")
    .resizable().scaledToFit()
    .scaleEffect(magnifyBy)
    .gesture(
        MagnifyGesture()
            .updating($magnifyBy) { value, state, _ in
                state = value.magnification
            }
    )

RotateGesture(iOS 17+)

RotateGesture 是 RotationGesture 的较新替代方案。跟踪双指旋转角度。

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

如需持久化、限制范围的放大示例和旋转组合示例,请加载 references/gesture-patterns.md。

手势组合

.simultaneously(with:) — 同时识别两个手势
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:) — 第一个手势必须成功,第二个手势才会开始
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:) — 只允许一个手势成功(第一个优先)
let doubleTapOrLongPress = TapGesture(count: 2)
    .exclusively(before:
        LongPressGesture()
    )
    .onEnded { result in
        switch result {
        case .first(_): handleDoubleTap()
        case .second(_): handleLongPress()
        }
    }

@GestureState

@GestureState 是一个属性包装器,当手势结束时会自动重置为初始值。用于临时反馈;需要保留的值则使用 @State。

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

使用动画自定义重置:@GestureState(resetTransaction: Transaction(animation: .spring))

为视图添加手势

三个修饰符用于控制视图层级中的手势优先级:

修饰符行为
.gesture()优先级低于视图或其子视图中已定义的手势。
.highPriorityGesture()添加的手势优先于现有手势。
.simultaneousGesture()添加的手势与现有手势以相同优先级处理。
let parentTap = TapGesture().onEnded { handleParent() }

VStack {
    Image(systemName: "star.fill")
        .onTapGesture { handleChild() }
}
.simultaneousGesture(parentTap) // Both handlers run on child content.

默认情况下,使用 .gesture(parentTap) 添加优先级较低的父视图手势;如果希望新增的父视图手势优先,则使用 .highPriorityGesture(parentTap)。

GestureMask

使用 .gesture(_:including:) 时,可控制哪些手势参与处理:

.gesture(drag, including: .gesture)   // added gesture; disables subview gestures
.gesture(drag, including: .subviews)  // subview gestures; disables added gesture
.gesture(drag, including: .all)       // default: added + subview gestures
.gesture(drag, including: .none)      // disables added + subview gestures

自定义手势协议

遵循 Gesture 协议即可创建可复用的手势:

struct SwipeGesture: Gesture {
    enum Direction { case left, right, up, down }
    typealias Value = Direction

    let minimumDistance: CGFloat

    init(minimumDistance: CGFloat = 50) {
        self.minimumDistance = minimumDistance
    }

    var body: AnyGesture<Direction> {
        AnyGesture(
            DragGesture(minimumDistance: minimumDistance)
                .map { value in
                    let h = value.translation.width, v = value.translation.height
                    if abs(h) > abs(v) {
                        return h > 0 ? .right : .left
                    } else {
                        return v > 0 ? .down : .up
                    }
                }
        )
    }
}

// Usage
Rectangle().gesture(SwipeGesture().onEnded { print("Swiped \($0)") })

将其封装在 View 扩展中,以便更方便地调用 API:

extension View {
    func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View {
        gesture(SwipeGesture().onEnded(action))
    }
}

常见错误

1. 误解父视图/子视图手势的优先级

不要假设父视图的 .gesture() 会覆盖子视图手势。请按照为视图添加手势中的说明,明确选择两者的关系。

2. 使用 @State 代替 @GestureState 存储临时状态

对于识别结束时应重置的值,请使用 @GestureState;持久化结果则保留在 @State 中。参见 @GestureState。

3. 未使用 .updating() 提供中间反馈
// 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 }
4. 在 iOS 17+ 中使用已弃用的手势类型
// DON'T: Deprecated since iOS 17
MagnificationGesture()   // deprecated — use MagnifyGesture()

// DO: Use newer gesture types
MagnifyGesture()         // iOS 17+
RotateGesture()          // iOS 17+ (newer alternative to RotationGesture)
5. 在 onChanged 中执行繁重计算
// 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
    }
6. 对应使用 Button 的操作使用 onTapGesture
// DON'T: onTapGesture has no accessibility traits, VoiceOver role,
// Voice Control targeting, Switch Control scanning, or keyboard activation
Text("Delete")
    .onTapGesture { deleteItem() }

// DO: Button provides all of these automatically
Button("Delete", role: .destructive) { deleteItem() }

// DO: For custom visuals, use ButtonStyle instead of onTapGesture
Button { toggleExpanded() } label: {
    CardView()
}
.buttonStyle(.plain)

仅在以下情况下使用 onTapGesture:多次点击(count: 2+)、行为取决于点击位置,或为已有适当无障碍特征的非交互内容添加点击识别。

检查清单

  • 手势类型正确:使用 MagnifyGesture/RotateGesture(而非已弃用的 Magnification/Rotation 变体)
  • 临时且应重置的值使用 @GestureState;持久化的值使用 @State
  • .updating() 在连续手势过程中提供中间视觉反馈
  • 使用 .highPriorityGesture() 或 .simultaneousGesture() 解决父视图/子视图冲突
  • onChanged 闭包保持轻量——不要在每一帧都执行繁重计算
  • 组合手势使用正确的组合方式:simultaneously、sequenced 或 exclusively
  • 在 onEnded 中将持久化的缩放/旋转限制在合理范围内
  • 自定义 Gesture 遵循实现应返回手势主体;映射到自定义 Value 时使用 AnyGesture<Value>
  • 手势驱动的动画使用 .spring 或类似效果,以实现自然减速
  • 在视图层级的不同层级混合手势时考虑 GestureMask
  • 仅在 count > 1、需要点击位置或坐标空间时使用 onTapGesture——普通的单击操作应使用 Button

参考资料

评论

支持 Markdown