swiftui-gestures by dpearson2699/swift-ios-skills
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-gestures审查、编写和修复 SwiftUI 手势交互。使用 Swift 6.2 模式,应用现代手势 API,实现正确的手势组合、状态管理和冲突解决。
| 手势 | 类型 | 值 | 起始版本 |
|---|---|---|---|
TapGesture | 离散型 | Void | iOS 13 |
LongPressGesture |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 离散型 |
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)。
识别一次或多次点击。使用 count 参数实现多点触控。
// 单点、双点和三点触控
TapGesture() .onEnded { tapped.toggle() }
TapGesture(count: 2) .onEnded { handleDoubleTap() }
TapGesture(count: 3) .onEnded { handleTripleTap() }
// 简写修饰符
Text("Tap me").onTapGesture(count: 2) { handleDoubleTap() }
在用户按住 minimumDuration 后成功。如果手指移动超过 maximumDistance 则失败。
// 基本长按(默认 0.5 秒)
LongPressGesture()
.onEnded { _ in showMenu = true }
// 自定义持续时间和距离容差
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:)。
跟踪手指移动。Value 提供 startLocation、location、translation、velocity 和 predictedEndTranslation。
@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)
取代已弃用的 MagnificationGesture。跟踪捏合缩放比例。
@GestureState private var magnifyBy = 1.0
Image("photo")
.resizable().scaledToFit()
.scaleEffect(magnifyBy)
.gesture(
MagnifyGesture()
.updating($magnifyBy) { value, state, _ in
state = value.magnification
}
)
保持缩放比例:
@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)
}
)
取代已弃用的 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 }
)
保持旋转角度:
@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 }
)
.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)
.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 是一个属性包装器,在手势结束时自动重置为其初始值。用于临时反馈;对于需要持久化的值,使用 @State。
@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))
三个修饰符控制视图层次结构中的手势优先级:
| 修饰符 | 行为 |
|---|---|
.gesture() | 默认优先级。子视图手势优先于父视图。 |
.highPriorityGesture() | 父视图手势优先于子视图。 |
.simultaneousGesture() | 父视图和子视图手势都会触发。 |
// 问题:父视图点击会吞掉子视图点击
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() })
在使用 .gesture(_:including:) 时控制哪些手势参与:
.gesture(drag, including: .gesture) // 仅此手势,不包括子视图
.gesture(drag, including: .subviews) // 仅子视图手势
.gesture(drag, including: .all) // 默认:此手势 + 子视图手势
通过遵循 Gesture 协议创建可重用的手势:
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:
extension View {
func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View {
gesture(SwipeGesture(onSwipe: action))
}
}
// 不要:父视图的 .gesture() 与子视图点击冲突
VStack {
Button("Action") { doSomething() }
}
.gesture(TapGesture().onEnded { parentAction() })
// 要:使用 .simultaneousGesture() 或 .highPriorityGesture()
VStack {
Button("Action") { doSomething() }
}
.simultaneousGesture(TapGesture().onEnded { parentAction() })
// 不要:@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
}
// 不要:长按期间没有视觉反馈
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 }
// 不要:自 iOS 17 起已弃用
MagnificationGesture() // 已弃用
RotationGesture() // 已弃用
// 要:使用现代替代品
MagnifyGesture() // iOS 17+
RotateGesture() // iOS 17+
// 不要:每帧(约 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) // 结束时执行一次
}
MagnifyGesture/RotateGesture(而非已弃用的 Magnification/Rotation 变体)@GestureState;对于持久化值使用 @State.updating() 在连续手势期间提供中间视觉反馈.highPriorityGesture() 或 .simultaneousGesture() 解决onChanged 闭包是轻量级的 — 没有每帧的繁重计算simultaneously、sequenced 或 exclusivelyonEnded 中被限制在合理的范围内Gesture 遵循使用 var body: some Gesture(而非 View).spring 或类似效果实现自然减速GestureMaskreferences/gesture-patterns.md 了解拖拽排序、捏合缩放、组合旋转+缩放、速度计算以及 SwiftUI/UIKit 手势互操作。每周安装量
344
代码仓库
GitHub 星标
269
首次出现
2026 年 3 月 8 日
安全审计
安装于
codex341
opencode338
github-copilot338
amp338
cline338
kimi-cli338
Review, write, and fix SwiftUI gesture interactions. Apply modern gesture APIs with correct composition, state management, and conflict resolution using Swift 6.2 patterns.
| Gesture | Type | Value | Since |
|---|---|---|---|
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).
Recognizes one or more taps. Use the count parameter for multi-tap.
// 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() }
Succeeds after the user holds for minimumDuration. Fails if finger moves beyond 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() }
With visual feedback via @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 }
)
Shorthand: .onLongPressGesture(minimumDuration:perform:onPressingChanged:).
Tracks finger movement. Value provides startLocation, location, translation, velocity, and predictedEndTranslation.
@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:
DragGesture(minimumDistance: 20, coordinateSpace: .global)
Replaces the deprecated MagnificationGesture. Tracks pinch-to-zoom scale.
@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:
@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)
}
)
Replaces the deprecated RotationGesture. Tracks two-finger rotation angle.
@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:
@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 }
)
.simultaneously(with:) — both gestures recognized at the same timelet 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.
.sequenced(before:) — first must succeed before second beginslet 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)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 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.
@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))
Three modifiers control gesture priority in the view hierarchy:
| Modifier | Behavior |
|---|---|
.gesture() | Default priority. Child gestures win over parent. |
.highPriorityGesture() | Parent gesture takes precedence over child. |
.simultaneousGesture() | Both parent and child gestures fire. |
// 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() })
Control which gestures participate when using .gesture(_:including:):
.gesture(drag, including: .gesture) // only this gesture, not subviews
.gesture(drag, including: .subviews) // only subview gestures
.gesture(drag, including: .all) // default: this + subviews
Create reusable gestures by conforming to Gesture:
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:
extension View {
func onSwipe(perform action: @escaping (SwipeGesture.Direction) -> Void) -> some View {
gesture(SwipeGesture(onSwipe: action))
}
}
// 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() })
// 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
}
// 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 }
// DON'T: Deprecated since iOS 17
MagnificationGesture() // deprecated
RotationGesture() // deprecated
// DO: Use modern replacements
MagnifyGesture() // iOS 17+
RotateGesture() // iOS 17+
// 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
}
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.highPriorityGesture() or .simultaneousGesture()onChanged closures are lightweight — no heavy computation every framereferences/gesture-patterns.md for drag-to-reorder, pinch-to-zoom, combined rotate+scale, velocity calculations, and SwiftUI/UIKit gesture interop.Weekly Installs
344
Repository
GitHub Stars
269
First Seen
Mar 8, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex341
opencode338
github-copilot338
amp338
cline338
kimi-cli338
Refero Design:研究优先设计方法,学习最佳实践,打造独特用户体验
838 周安装
竞争对手研究指南:SEO、内容、反向链接与定价分析工具
231 周安装
Azure 工作负载自动升级评估工具 - 支持 Functions、App Service 计划与 SKU 迁移
231 周安装
Kaizen持续改进方法论:软件开发中的渐进式优化与防错设计实践指南
231 周安装
软件UI/UX设计指南:以用户为中心的设计原则、WCAG可访问性与平台规范
231 周安装
Apify 网络爬虫和自动化平台 - 无需编码抓取亚马逊、谷歌、领英等网站数据
231 周安装
llama.cpp 中文指南:纯 C/C++ LLM 推理,CPU/非 NVIDIA 硬件优化部署
231 周安装
simultaneously, sequenced, or exclusivelyonEndedGesture conformances use var body: some Gesture (not View).spring or similar for natural decelerationGestureMask considered when mixing gestures across view hierarchy levels