关于 axiom-swiftui-gestures
面向 SwiftUI 开发者的手势指南,介绍手势识别、组合、状态管理、无障碍支持与跨平台处理,可用于实现交互或排查手势冲突。
主要功能
- 01点击、拖动、长按、缩放和旋转手势
- 02同时、顺序与排他手势组合
- 03使用 GestureState 管理临时状态
- 04自定义手势及速度预测
- 05VoiceOver 与键盘替代操作
适用场景
- 01实现长按后拖动排序
- 02让图片支持拖动和捏合缩放
- 03排查手势与 ScrollView 的冲突
- 04为 VoiceOver 用户提供手势替代操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-swiftui-gestures安装后在 Claude Code 中输入/axiom-swiftui-gestures确认已加载。
详细介绍
SwiftUI 手势
关于 SwiftUI 手势识别的全面指南,涵盖组合模式、状态管理和无障碍集成。
何时使用此技能
- 实现轻点、拖动、长按、缩放或旋转手势
- 组合多个手势(同时、按顺序、互斥)
- 使用 GestureState 管理手势状态
- 创建自定义手势识别器
- 调试手势冲突或无响应的手势
- 让手势可供 VoiceOver 用户使用
- 跨平台手势处理(iOS、macOS、axiom-visionOS)
示例提示
以下是开发者实际会提出的问题,而此技能旨在解答这些问题:
1. “我的拖动手势不起作用——拖动时视图不会移动。该如何调试?”
→ 此技能介绍 DragGesture 的状态管理模式,并演示如何使用 @GestureState 正确更新视图偏移量
2. “我在同一个视图上同时添加了轻点手势和拖动手势。轻点有效,但拖动无效。该如何修复?”
→ 此技能演示如何使用 .simultaneously、.sequenced 和 .exclusively 组合手势,以解决手势冲突
3. “我希望用户先长按,然后才能拖动项目。该如何将手势串联起来?”
→ 此技能介绍 .sequenced 模式,演示如何按正确顺序组合 LongPressGesture 和 DragGesture
4. “手势结束时,手势状态没有重置。视图停留在错误的位置。”
→ 此技能介绍 @GestureState 的自动重置行为,以及如何使用 updating 参数正确管理状态
5. “VoiceOver 用户无法使用需要手势的功能。该如何让手势变得无障碍?”
→ 此技能演示 .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
模式 1:基本手势识别
TapGesture
❌ 错误(在非语义视图上自定义轻点)
Text("Submit")
.onTapGesture {
submitForm()
}
问题:
- VoiceOver 不会将其播报为按钮
- 没有按下时的视觉反馈
- 不遵循无障碍设置
✅ 正确(轻点操作应使用 Button)
Button("Submit") {
submitForm()
}
.buttonStyle(.bordered)
何时使用 TapGesture:仅当需要轻点数据(位置、次数)或非标准轻点行为时:
Image("map")
.onTapGesture(count: 2) { // Double-tap for details
showDetails()
}
.onTapGesture { location in // Single tap to pin
addPin(at: location)
}
DragGesture
❌ 错误(在手势中直接修改状态)
@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
}
)
}
问题:
- 每次拖动事件都会更新视图(每秒 60–120 次)
- 无法重置到原始位置
- 拖动取消时会丢失中间状态
✅ 正确(使用 GestureState 管理临时状态)
@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
}
)
}
原因:手势结束时,GestureState 会自动重置为初始值,从而避免状态损坏。
LongPressGesture
@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
}
)
}
关键参数:
minimumDuration:按住的时长(默认 0.5 秒)maximumDistance:取消前手指可移动的距离(默认 10 点)
MagnificationGesture
@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
@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
}
)
}
模式 2:手势组合
同时执行手势
适用场景:两个手势应同时生效
@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) // Double-tap
.onEnded { _ in
zoom()
}
.exclusively(before:
TapGesture(count: 1) // Single tap
.onEnded { _ in
select()
}
)
)
}
原因:如果不使用 .exclusively,双击会同时触发单击和双击处理程序。
工作原理:SwiftUI 会等待,判断是否会出现第二次轻点。如果会 → 双击生效;如果不会 → 单击生效。
模式 3:GestureState 与 State
何时使用各类型
| 使用场景 | 状态类型 | 原因 |
|---|---|---|
| 手势期间的临时反馈 | @GestureState | 手势结束时自动重置 |
| 最终提交的值 | @State | 手势结束后仍会保留 |
| 手势期间的动画 | @GestureState | 过渡流畅 |
| 数据持久化 | @State | 在视图更新后仍会保留 |
完整示例:可拖动卡片
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
}
}
)
}
}
关键要点:GestureState 的第三个参数 transaction 可让你自定义手势期间的动画。
模式 4:自定义手势
何时创建自定义手势
- 需要内置手势未提供的手势行为
- 希望封装复杂的手势逻辑
- 在多个视图中重复使用手势
示例:带方向的滑动手势
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
}
}
)
模式 5:手势速度和预测
获取速度
@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)
}
}
)
}
预测结束位置(iOS 16+)
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)
}
使用场景:弹性物理效果、惯性滚动、抛掷动画。
模式 6:无障碍集成
让自定义手势支持无障碍功能
❌ 错误(仅支持手势,不支持 VoiceOver)
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 用户现在可以向上或向下轻扫来调整音量,无需看到或使用该手势。
键盘替代操作(macOS)
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
}
模式 7:跨平台手势
iOS、macOS 和 visionOS 对比
| 手势 | iOS | macOS | visionOS |
|---|---|---|---|
| TapGesture | 用手指轻点 | 用鼠标或触控板点击 | 注视并捏合 |
| DragGesture | 用手指拖动 | 点击并拖动 | 捏合并移动 |
| LongPressGesture | 长按 | 点击并按住 | 长时间捏合 |
| MagnificationGesture | 双指捏合 | 触控板捏合 | 双手捏合 |
| RotationGesture | 双指旋转 | 触控板旋转 | 双手旋转 |
平台专属手势
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)
}
)
}
常见问题
问题 1:忘记重置 GestureState
❌ 错误
@State private var offset = CGSize.zero // Should be GestureState
var body: some View {
Circle()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
)
}
问题:拖动结束时,偏移量停留在最后的值,而不是重置。
修复方法:临时状态使用 @GestureState,或在 .onEnded 中手动重置。
问题 2:手势与 ScrollView 冲突
❌ 错误(拖动手势会阻止滚动)
ScrollView {
ForEach(items) { item in
ItemView(item)
.gesture(
DragGesture()
.onChanged { _ in
// Prevents scroll!
}
)
}
}
修复方法:根据需要使用 .highPriorityGesture() 或 .simultaneousGesture():
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)
}
}
)
}
}
问题 3:使用 .gesture() 而不是 Button
❌ 错误(重新实现按钮)
Text("Submit")
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture {
submit()
}
问题:
- 没有按下动画
- 没有无障碍特征
- 不遵循系统按钮样式
- 代码更多
✅ 正确
Button("Submit") {
submit()
}
.buttonStyle(.borderedProminent)
TapGesture 适用的情况:需要获取轻点位置或多次轻点的次数时:
Canvas { context, size in
// Draw canvas
}
.onTapGesture { location in
addShape(at: location) // Need location data
}
问题 4:未处理手势取消
❌ 错误(假定手势总会完成)
DragGesture()
.onChanged { value in
showPreview(at: value.location)
}
.onEnded { value in
hidePreview()
commitChange(at: value.location)
}
问题:如果用户将手指拖出边界,导致手势取消,预览仍会显示。
✅ 正确(GestureState 会自动重置)
@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
}
}
}
问题 5:忘记 coordinateSpace
❌ 错误(位置相对于视图,而不是屏幕)
DragGesture()
.onChanged { value in
// value.location is relative to the gesture's view
addAnnotation(at: value.location)
}
问题:如果视图发生偏移或滚动,坐标就会不正确。
✅ 正确(指定坐标空间)
DragGesture(coordinateSpace: .named("container"))
.onChanged { value in
addAnnotation(at: value.location) // Relative to "container"
}
// In parent:
ScrollView {
content
}
.coordinateSpace(name: "container")
选项:
.local— 相对于手势所在的视图(默认).global— 相对于屏幕.named("name")— 相对于命名坐标空间
性能注意事项
尽量减少 .onChanged 中的工作
❌ 慢
DragGesture()
.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)
}
)
}
使用 Transaction 实现流畅动画
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)
}
}
原因:手势期间的动画可能显得迟滞。拖动期间禁用动画,在最终吸附时启用动画。
故障排除
手势无法识别
检查:
- 视图是否可交互?(某些视图(如
Text)会忽略手势,除非将其包装起来) - 是否有其他手势优先级更高?(使用
.highPriorityGesture()或.simultaneousGesture()) - 视图是否被裁剪?(使用
.contentShape()定义点击区域) - 手势限制是否过多?(检查
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()
}
)
}
手势影响 ScrollView
仅检测水平手势:
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)
}
}
}
)
}
}
测试手势
使用手势进行 UI 测试
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)
}
手动测试清单
- 手势在首次交互时即可生效(无需“预热”)
- 手势可以取消(拖动到边界之外)
- 多次快速触发手势时均能正常工作
- 启用 VoiceOver 后手势仍能正常工作
- 手势在所有目标平台(iOS/macOS/visionOS)上均能正常工作
- 手势不会阻碍滚动或导航
- 交互过程中手势会提供视觉反馈
- 手势遵循辅助功能设置(减少动态效果)
资源
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),而不是自定义手势。手势应增强交互体验,而非取代标准控件。