axiom-swiftui-gestures by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-gesturesSwiftUI 手势识别的综合指南,涵盖组合模式、状态管理和无障碍功能集成。
这些是开发者实际提出的问题,本技能旨在解答:
→ 本技能涵盖 DragGesture 状态管理模式,并展示如何正确使用 @GestureState 更新视图偏移量
→ 本技能演示使用 .simultaneously、.sequenced 和 .exclusively 进行手势组合以解决手势冲突
→ 本技能展示按正确顺序组合 LongPressGesture 和 DragGesture 的 .sequenced 模式
→ 本技能涵盖 @GestureState 的自动重置行为以及用于正确状态管理的 updating 参数
→ 本技能演示 .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
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
Text("Submit")
.onTapGesture {
submitForm()
}
问题:
Button("Submit") {
submitForm()
}
.buttonStyle(.bordered)
何时使用 TapGesture:仅当你需要点击 数据(位置、次数)或非标准点击行为时:
Image("map")
.onTapGesture(count: 2) { // 双击查看详情
showDetails()
}
.onTapGesture { location in // 单点击添加图钉
addPin(at: location)
}
@State private var offset = CGSize.zero
var body: some View {
Circle()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation // ❌ 每帧都更新,导致卡顿
}
)
}
问题:
@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 在手势结束时自动重置为初始值,防止状态损坏。
@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 // 长按后的操作
}
)
}
关键参数:
minimumDuration:需要长按的时间(默认 0.5 秒)maximumDistance:手指在取消前可以移动的距离(默认 10 点)@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
}
)
}
平台注意事项:
@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
}
)
}
@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) // 双击
.onEnded { _ in
zoom()
}
.exclusively(before:
TapGesture(count: 1) // 单击
.onEnded { _ in
select()
}
)
)
}
原因:没有 .exclusively,双击会触发 两个 单击和双击处理器。
工作原理:SwiftUI 等待查看是否有第二次点击。如果有 → 双击生效。如果没有 → 单击生效。
| 使用场景 | 状态类型 | 原因 |
|---|---|---|
| 手势期间的临时反馈 | @GestureState | 手势结束时自动重置 |
| 最终提交的值 | @State | 手势结束后持久保存 |
| 手势期间的动画 | @GestureState | 平滑过渡 |
| 数据持久化 | @State | 在视图更新后保留 |
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 允许你在手势期间自定义动画。
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
}
}
)
@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
)
// 使用动量进行动画
withAnimation(.interpolatingSpring(stiffness: 100, damping: 15)) {
applyMomentum(velocity: velocity)
}
}
)
}
DragGesture()
.onChanged { value in
// 基于速度预测手势可能结束的位置
let predicted = value.predictedEndLocation
// 显示项目将落在何处的预览
showPreview(at: predicted)
}
使用案例:弹性物理效果、动量滚动、投掷动画。
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 用户现在可以通过上下滑动来调整音量,而无需看到或使用手势。
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
}
| 手势 | iOS | macOS | visionOS |
|---|---|---|---|
| TapGesture | 手指点击 | 鼠标/触控板点击 | 注视 + 捏合 |
| DragGesture | 手指拖拽 | 点击并拖拽 | 捏合并移动 |
| LongPressGesture | 长按 | 点击并按住 | 长捏合 |
| MagnificationGesture | 双指捏合 | 触控板捏合 | 双手捏合 |
| RotationGesture | 双指旋转 | 触控板旋转 | 双手旋转 |
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)
}
)
}
@State private var offset = CGSize.zero // 应该是 GestureState
var body: some View {
Circle()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
)
}
问题:拖拽结束时,偏移量保持在最后一个值而不是重置。
修复:使用 @GestureState 处理临时状态,或在 .onEnded 中手动重置。
ScrollView {
ForEach(items) { item in
ItemView(item)
.gesture(
DragGesture()
.onChanged { _ in
// 阻止滚动!
}
)
}
}
修复:适当使用 .highPriorityGesture() 或 .simultaneousGesture():
ScrollView {
ForEach(items) { item in
ItemView(item)
.simultaneousGesture( // 允许同时滚动和拖拽
DragGesture()
.onChanged { value in
// 仅当水平滑动时触发
if abs(value.translation.width) > abs(value.translation.height) {
handleSwipe(value)
}
}
)
}
}
Text("Submit")
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture {
submit()
}
问题:
Button("Submit") {
submit()
}
.buttonStyle(.borderedProminent)
何时使用 TapGesture 是合适的:当你需要点击 位置 或多次点击计数时:
Canvas { context, size in
// 绘制画布
}
.onTapGesture { location in
addShape(at: location) // 需要位置数据
}
DragGesture()
.onChanged { value in
showPreview(at: value.location)
}
.onEnded { value in
hidePreview()
commitChange(at: value.location)
}
问题:如果用户拖拽到边界外且手势取消,预览会保持可见。
@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() // 取消时清理
}
}
}
DragGesture()
.onChanged { value in
// value.location 相对于手势的视图
addAnnotation(at: value.location)
}
问题:如果视图有偏移/滚动,坐标会出错。
DragGesture(coordinateSpace: .named("container"))
.onChanged { value in
addAnnotation(at: value.location) // 相对于 "container"
}
// 在父视图中:
ScrollView {
content
}
.coordinateSpace(name: "container")
选项:
.local — 相对于手势的视图(默认).global — 相对于屏幕.named("name") — 相对于命名的坐标空间DragGesture()
.onChanged { value in
// 每秒调用 60-120 次!
let position = complexCalculation(value.translation)
updateDatabase(position) // ❌ 手势中的 I/O 操作
reloadAllViews() // ❌ 繁重工作
}
@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)
}
)
}
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)
}
}
原因:手势期间的动画可能感觉迟钝。在拖拽期间禁用,为最终吸附启用。
检查:
Text 除非被包装,否则忽略手势).highPriorityGesture() 或 .simultaneousGesture()).contentShape() 定义点击区域)minimumDistance、minimumDuration)// 修复无响应手势
Text("Tap me")
.frame(width: 100, height: 100)
.contentShape(Rectangle()) // 定义完整点击区域
.onTapGesture {
handleTap()
}
NavigationLink(destination: DetailView()) {
ItemRow(item)
.simultaneousGesture( // 不要阻止导航
LongPressGesture()
.onEnded { _ in
showContextMenu()
}
)
}
使用仅水平方向的手势检测:
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)
}
}
}
)
}
}
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)
}
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)而不是自定义手势。手势应该增强交互,而不是替代标准控件。
每周安装量
122
代码仓库
GitHub 星标数
601
首次出现
2026 年 1 月 21 日
安全审计
安装于
opencode106
codex99
claude-code95
gemini-cli94
cursor91
github-copilot88
Comprehensive guide to SwiftUI gesture recognition with composition patterns, state management, and accessibility integration.
These are real questions developers ask that this skill is designed to answer:
→ The skill covers DragGesture state management patterns and shows how to properly update view offset with @GestureState
→ The skill demonstrates gesture composition with .simultaneously, .sequenced, and .exclusively to resolve gesture conflicts
→ The skill shows the .sequenced pattern for combining LongPressGesture with DragGesture in the correct order
→ The skill covers @GestureState automatic reset behavior and the updating parameter for proper state management
→ The skill demonstrates .accessibilityAction patterns and providing alternative interactions for VoiceOver users
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
Text("Submit")
.onTapGesture {
submitForm()
}
Problems :
Button("Submit") {
submitForm()
}
.buttonStyle(.bordered)
When to use TapGesture : Only when you need tap data (location, count) or non-standard tap behavior:
Image("map")
.onTapGesture(count: 2) { // Double-tap for details
showDetails()
}
.onTapGesture { location in // Single tap to pin
addPin(at: location)
}
@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 :
@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.
@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 :
minimumDuration: How long to hold (default 0.5 seconds)maximumDistance: How far finger can move before cancelling (default 10 points)@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 :
@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
}
)
}
@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.
@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.
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 .exclusively, double-tap triggers both single and double tap handlers.
How it works : SwiftUI waits to see if second tap comes. If yes → double tap wins. If no → single tap wins.
| Use Case | State Type | Why |
|---|---|---|
| Temporary feedback during gesture | @GestureState | Auto-resets when gesture ends |
| Final committed value | @State | Persists after gesture |
| Animation during gesture | @GestureState | Smooth transitions |
| Data persistence | @State | Survives view updates |
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 transaction lets you customize animation during the gesture.
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
}
}
)
@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)
}
}
)
}
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.
Image("slider")
.gesture(
DragGesture()
.onChanged { value in
updateVolume(value.translation.width)
}
)
Problem : VoiceOver users can't adjust the slider.
@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.
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
}
| 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 |
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)
}
)
}
@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 @GestureState for temporary state, or manually reset in .onEnded.
ScrollView {
ForEach(items) { item in
ItemView(item)
.gesture(
DragGesture()
.onChanged { _ in
// Prevents scroll!
}
)
}
}
Fix : Use .highPriorityGesture() or .simultaneousGesture() appropriately:
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)
}
}
)
}
}
Text("Submit")
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture {
submit()
}
Problems :
Button("Submit") {
submit()
}
.buttonStyle(.borderedProminent)
When TapGesture is OK : When you need tap location or multiple tap counts:
Canvas { context, size in
// Draw canvas
}
.onTapGesture { location in
addShape(at: location) // Need location data
}
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.
@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
}
}
}
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.
DragGesture(coordinateSpace: .named("container"))
.onChanged { value in
addAnnotation(at: value.location) // Relative to "container"
}
// In parent:
ScrollView {
content
}
.coordinateSpace(name: "container")
Options :
.local — Relative to gesture's view (default).global — Relative to screen.named("name") — Relative to named coordinate spaceDragGesture()
.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)
}
)
}
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.
Check :
Text ignore gestures unless wrapped).highPriorityGesture() or .simultaneousGesture()).contentShape() to define tap area)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()
}
)
}
Use horizontal-only gesture detection :
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)
}
}
}
)
}
}
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)
}
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.
Weekly Installs
122
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode106
codex99
claude-code95
gemini-cli94
cursor91
github-copilot88
agentation - AI智能体可视化UI反馈工具,连接人眼与代码的桥梁
5,400 周安装
Chrome DevTools 浏览器自动化与调试技能 - 网页性能分析、自动化测试工具
9,500 周安装
PostgreSQL优化助手 - JSONB操作、性能调优、窗口函数、全文搜索实战指南
9,600 周安装
GitHub Copilot create-readme:AI自动生成专业README文档工具
9,600 周安装
React Native 最佳实践与性能优化指南 | 提升应用FPS、启动速度与包体积
9,600 周安装
Web无障碍性(a11y)指南:WCAG 2.1原则、Lighthouse审计与代码实践
10,500 周安装
Vue Router 最佳实践指南:导航守卫、路由生命周期与常见陷阱解决方案
9,900 周安装