上一章节 介绍了 SwiftUI 中如何为列表添加左滑删除、长按触发上下文菜单和点击弹出 Action Sheet 等互动功能。 本章将进一步扩展这个功能,深入探讨如何让用户与列表产生更多交互。
本章将进一步探讨 SwiftUI 提供的多种内置手势,以及如何将这些手势与视图组合使用。
我们会先深入了解几种常见的内置手势:TapGesture、LongPressGesture、DragGesture 等,
最后还会展示如何构建一个通用的拖拽视图组件,让你能够方便地将“拖拽”功能应用于任意 View。
使用 .gesture 修饰器
在 SwiftUI 中,我们可以对任意视图使用 .gesture 修饰器来附加一个手势识别器(Gesture Recognizer)。
以下示例在一个 Image 视图上附加了一个简单的 TapGesture:
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.foregroundStyle(.green)
.gesture(
TapGesture()
.onEnded {
print("Tapped!")
}
)
}
如果要测试该代码,你可以新建一个 SwiftUI 项目,然后把上述代码粘贴到 ContentView.swift 中运行即可。
下面的示例在这个基础上稍作改动,增加一个缩放的动画效果。
我们用 @State 修饰一个布尔变量 isPressed 来控制图像的缩放:
struct ContentView: View {
@State private var isPressed = false
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.scaleEffect(isPressed ? 0.5 : 1.0)
.animation(.easeInOut, value: isPressed)
.foregroundStyle(.green)
.gesture(
TapGesture()
.onEnded {
self.isPressed.toggle()
}
)
}
}
现在,当你点击星形图标时,它会在两种缩放状态之间切换,并带有平滑的动画。
这展示了使用 .gesture 来识别特定手势并结合动画的简单示例。
长按手势(Long Press Gesture)
除了点击手势之外,SwiftUI 还内置了 LongPressGesture,用来侦听长按事件。
若想在按住图像超过 1 秒后才进行某种行为,例如让图像缩放,可以这样做:
.gesture(
LongPressGesture(minimumDuration: 1.0)
.onEnded { _ in
self.isPressed.toggle()
}
)
现在,你需要按住星形图标至少 1 秒,才会触发 .onEnded 闭包,进行缩放切换。
使用 @GestureState 来追踪手势状态
当使用长按手势时,你可能会注意到一个 UX 问题:用户按下图像的一瞬间,屏幕并没有任何反馈。 只有当触发了长按 1 秒后,图像才会切换缩放状态。 我们可以在用户开始按下图像时就给予一些视觉反馈(比如降低图像的不透明度),从而提升用户体验。
要区分“手指刚按下”与“长按结束”,SwiftUI 提供了一个非常有用的属性包装器:@GestureState。
它可以让我们在手势执行的不同阶段更新状态,并在手势结束后自动重置其值。
下面示例中,我们添加一个 @GestureState 属性 longPressTap,
并配合 .updating 方法来追踪手势状态:
@GestureState private var longPressTap = false
更新 Image 视图代码如下:
Image(systemName: "star.circle.fill")
.font(.system(size: 200))
.opacity(longPressTap ? 0.4 : 1.0)
.scaleEffect(isPressed ? 0.5 : 1.0)
.animation(.easeInOut, value: isPressed)
.foregroundStyle(.green)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.updating($longPressTap) { currentState, state, _ in
state = currentState
}
.onEnded { _ in
self.isPressed.toggle()
}
)
opacity(longPressTap ? 0.4 : 1.0):当检测到手指按下时(longPressTap为true),把图像透明度降到0.4。updating($longPressTap) { currentState, state, transaction in ... }:在长按手势进行的过程中,该闭包会不停调用,我们将state更新为currentState。- 当手势结束时,
@GestureState会将longPressTap自动重置为初始值(false)。
运行后,你会发现用户长按图像的瞬间,图像就会变暗,长按满 1 秒后才执行大小切换效果。
拖拽手势(Drag Gesture)
除了点击、长按,还有一个非常常见的手势是 拖拽(Drag)。 下面演示如何让用户能够拖拽星形图标到任意位置。
首先,我们在 ContentView 里用 @GestureState 声明一个 dragOffset 来追踪拖拽的偏移量:
struct ContentView: View {
@GestureState private var dragOffset = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.offset(x: dragOffset.width, y: dragOffset.height)
.animation(.easeInOut, value: dragOffset)
.foregroundStyle(.green)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
)
}
}
- 当用户在屏幕上拖拽图像时,
DragGesture会不断调用.updating闭包,其中value.translation存储了当前拖动的位移。我们把它赋值给state,即dragOffset。 - 拖拽结束后,
dragOffset会自动重置为.zero,也就意味着图像会回到初始位置。
如何让图像留在拖拽结束的位置?
@GestureState 的特性决定了它会在手势结束时重置,我们可以增加一个 @State 属性来持久化最终位置。示例代码:
@State private var position = CGSize.zero
@GestureState private var dragOffset = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.offset(x: position.width + dragOffset.width,
y: position.height + dragOffset.height)
.animation(.easeInOut, value: dragOffset)
.foregroundStyle(.green)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
self.position.width += value.translation.width
self.position.height += value.translation.height
}
)
}
position表示图像的最终位置。- 在
onEnded闭包里,将这次拖拽的位移加到原有位置上。 offset的位置会以position + dragOffset来计算。
现在星形图标会始终停留在拖拽结束的位置。
组合多个手势
有时,我们需要在同一个视图上组合多个手势。 例如:必须先长按 1 秒,才能够开始拖拽。
SwiftUI 提供了三种手势组合方式:
- Simultaneous:同时侦听多个手势。
- Exclusive:当检测到其中一个手势后,忽略其他手势。
- Sequenced:按顺序识别多个手势。
这里我们用 Sequenced 来让用户先长按,再进行拖拽。
示例代码如下:
struct ContentView: View {
@GestureState private var isPressed = false
@GestureState private var dragOffset = CGSize.zero
@State private var position = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.opacity(isPressed ? 0.5 : 1.0)
.offset(x: position.width + dragOffset.width,
y: position.height + dragOffset.height)
.animation(.easeInOut, value: dragOffset)
.foregroundColor(.green)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.updating($isPressed) { currentState, state, _ in
state = currentState
}
.sequenced(before: DragGesture())
.updating($dragOffset) { value, state, _ in
switch value {
case .first(true):
// 正在长按阶段
print("Tapping")
case .second(true, let drag):
// 已成功长按,进入拖拽阶段
state = drag?.translation ?? .zero
default:
break
}
}
.onEnded { value in
// 长按 + 拖拽结束后,更新最终位置
guard case .second(true, let drag?) = value else {
return
}
self.position.width += drag.translation.width
self.position.height += drag.translation.height
}
)
}
}
- 先创建
LongPressGesture,再用.sequenced(before:)连接上DragGesture。 - 在
.updating中,根据当前手势阶段(.first 或 .second)来判断是长按还是拖拽,并更新对应的状态值。 - 在
.onEnded里,同样要注意区别手势阶段,只有当第二阶段(拖拽)结束时,才更新位置。
现在用户必须先按住图像 1 秒,才能开始拖动星形图标。
使用 Enum 重构手势状态
在上面的例子中,我们分别用 isPressed 和 dragOffset 来表示不同手势的状态。
如果手势状态复杂(例如需要更多中间状态),这会让代码变得不太容易维护。
一种更好的做法是借助 枚举 来统一管理手势状态。
如下所示:
enum DragState {
case inactive
case pressing
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive, .pressing:
return .zero
case .dragging(let translation):
return translation
}
}
var isPressing: Bool {
switch self {
case .pressing, .dragging:
return true
case .inactive:
return false
}
}
}
inactive:手势未触发。pressing:正在长按。dragging(translation: CGSize):正在拖拽,并携带一个位移值。
随后,我们只需要一个 @GestureState 来追踪当前的枚举状态:
@GestureState private var dragState = DragState.inactive
@State private var position = CGSize.zero
var body: some View {
Image(systemName: "star.circle.fill")
.font(.system(size: 100))
.opacity(dragState.isPressing ? 0.5 : 1.0)
.offset(x: position.width + dragState.translation.width,
y: position.height + dragState.translation.height)
.animation(.easeInOut, value: dragState.translation)
.foregroundColor(.green)
.gesture(
LongPressGesture(minimumDuration: 1.0)
.sequenced(before: DragGesture())
.updating($dragState) { value, state, _ in
switch value {
case .first(true):
state = .pressing
case .second(true, let drag):
state = .dragging(translation: drag?.translation ?? .zero)
default:
break
}
}
.onEnded { value in
guard case .second(true, let drag?) = value else {
return
}
self.position.width += drag.translation.width
self.position.height += drag.translation.height
}
)
}
这样不仅使状态管理更加明晰,而且当需要扩展新的手势状态时,只需在枚举中添加新 case 并在对应的分支中处理即可,具有更好的可维护性。