在 Mac 应用中拖动形状时,在手势过程中直接更新它的偏移量,然后在释放时为同一偏移量添加弹簧动画。在 macOS 14 及更高版本中,SwiftUI 可以自动将跟踪到的拖动速度带入弹簧动画。
本教程将构建一个简单的桌面交互:向任意方向拖动蓝色合成方块,松开后让它弹回起始位置。所有状态都保存在内存中,手势由应用自身窗口中的方块处理。
弹簧如何继承拖动结束时的速度?
SwiftUI 的 Transaction.tracksVelocity 会在可动画属性发生变化时记录速度。Apple 文档说明,手势 onChanged 和 updating 回调会自动启用此跟踪。交互结束时,动画可以使用累计的速度。因此,对于这种常见的 SwiftUI 交互,你无需计算并传入初始速度。Apple 的速度跟踪文档明确说明了这种交接。
关键在于动画属性。拖动过程中,将手势的位移写入方块的偏移量。松开时,在 withAnimation 中将同一偏移量改为目标位置。让视图及其状态保持存续,SwiftUI 才能持续跟踪这个属性。
在此实现中,拖动更新不添加动画。Apple 解释说,事务速度跟踪与自行管理速度的动画互不兼容。拖动过程中使用交互式弹簧也是一种受支持的技巧,但这会改变方块跟随指针的方式;对于这里的直接跟随交互来说并无必要。事务速度跟踪
弹簧的目标位置和初始运动各有作用。目标位置决定方块最终停在哪里。继承的速度则表示方块松开时的运动状态。若方块在远离起点的方向上运动时松开,它起初可能会继续向外移动,然后再被弹簧拉回。Apple 在使用弹簧创建动画中演示了弹簧如何延续手势速度带来的运动。
构建完整的 macOS 拖动与释放示例
在 Xcode 中使用 SwiftUI 创建一个 macOS 应用,将部署目标设为 macOS 14.0 或更高版本,然后用以下代码替换生成的应用入口文件。目标中只保留一个 @main 应用声明。此示例使用 macOS 14 的动画完成 API。Apple 的动画完成文档
这是一个完整的单文件应用,无需资源文件或软件包依赖。代码中的 API 签名已对照文档核验;在本次研究环境中未编译或运行桌面应用。
import SwiftUI
@main
struct SpringReleaseDemoApp: App {
var body: some Scene {
WindowGroup {
SpringReleaseView()
}
}
}
struct SpringReleaseView: View {
@State private var offset: CGSize = .zero
@State private var isReturning = false
@GestureState private var isDragging = false
var body: some View {
VStack(spacing: 16) {
Text("Drag the tile, then release")
.font(.headline)
ZStack {
RoundedRectangle(cornerRadius: 20)
.stroke(.gray.opacity(0.4), lineWidth: 2)
.frame(width: 112, height: 112)
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 112, height: 112)
.contentShape(Rectangle())
.offset(offset)
.gesture(tileDrag)
.allowsHitTesting(!isReturning)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.coordinateSpace(name: "canvas")
}
.padding(24)
.frame(width: 640, height: 440)
.onChange(of: isDragging) { _, active in
guard !active, !isReturning else { return }
// Cancellation cleanup has no successful release value.
withTransaction(Transaction(animation: nil)) {
offset = .zero
}
}
}
private var tileDrag: some Gesture {
DragGesture(
minimumDistance: 3,
coordinateSpace: .named("canvas")
)
.updating($isDragging) { _, active, _ in
active = true
}
.onChanged { value in
// Gesture callbacks automatically track velocity.
offset = value.translation
}
.onEnded { _ in
returnHome()
}
}
private func returnHome() {
guard !isReturning else { return }
isReturning = true
withAnimation(
.spring(duration: 0.45, bounce: 0.2),
completionCriteria: .removed
) {
offset = .zero
} completion: {
isReturning = false
}
}
}
手势附加在移动的方块上,而具名坐标空间属于静止的画布。这样可以明确指定交互使用的坐标空间。Apple 的 DragGesture 文档介绍了如何通过手势修饰符附加配置好的拖动手势。
value.translation 表示从拖动开始到现在的总位移。将它赋给 offset;不要反复把它加到之前的偏移量上,否则同一段移动会被重复累加。本示例中,每次被接受的拖动都从原位开始,因此无需单独保存起始偏移量。Apple 对 translation 的定义
成功松开时会立即调用 returnHome()。它不会预先重置偏移量,也不会延迟启动弹簧动画。这样,跟踪到的拖动变化和释放动画就会通过同一个状态属性衔接起来。
为什么要分别保存拖动、位置和返回状态?
这三个属性回答不同的问题:offset 保存请求的位移,isDragging 跟踪当前是否有手势进行中,isReturning 则记录示例所处的返回阶段。方块所属的视图在本地持有这些值。Apple 建议将视图自身拥有的状态声明为私有,并且只在其他视图需要修改时共享绑定。State 文档
属性包装器语法也有具体用途。Swift 属性包装器负责管理对被包装值的访问,并可通过美元符号前缀提供投影值。这里,$isDragging 提供 updating 使用的手势状态投影;它不是另一个单独维护的标志。《Swift 编程语言:属性》
手势取消需要单独处理,因为只有手势成功完成时才会调用 onEnded。Apple 建议使用 @GestureState 来管理无论手势如何结束都必须重置的状态。此示例会监视该状态的重置;如果正常返回流程尚未进行,就将合成方块复位。此回退会让方块直接跳回原位,并不声称取消的手势具有最终释放速度。手势完成行为、GestureState
此示例还特意规定,必须等返回动画结束后才能接受新的拖动。将模型中的偏移量设为零会启动动画,因此即使方块仍在屏幕上移动,零也已经是请求的目标位置。若直接移除这项限制,就需要设计新拖动如何从当前显示位置接手。
allowsHitTesting(!isReturning) 控制方块是否参与命中测试。当整个动画结束时,.removed 完成条件会解除限制。这样就无需根据弹簧的持续时间参数猜测延迟。命中测试控制、动画移除完成条件
如何调整并检查释放效果?
先固定目标位置和弹簧设置。调整动画参数之前,先改变释放方块的方式。这样可以将速度继承与弹簧调校区分开来。
在窗口内按以下步骤检查。这是根据文档所述的交接机制整理出的诊断流程,并非本示例的实测结果。
| 检查项 | 操作 | 观察内容 |
|---|---|---|
| 几乎静止时释放 | 将方块从原位拖开,暂停,然后松开。 | 回程开始时应几乎没有继承的运动。 |
| 向外运动时释放 | 将方块从原位拖开,并在它仍向外运动时松开。 | 弹簧应先延续向外的运动,然后再将它拉回。 |
| 向内运动时释放 | 将方块拖开,再反向朝原位移动,然后松开。 | 回程应延续向内的运动。 |
| 斜向移动时释放 | 沿两个轴移动时松开。 | 检查交接是否保留移动方向,而不是只进行水平回程。 |
| 下一次交互 | 等回程结束后再次拖动。 | 新的拖动应从原位开始。 |
调整 duration 可改变节奏,调整 bounce 可改变弹簧特性。示例中的 0.45 和 0.2 只是用于起步的示例值,并非规定设置。Apple 将 duration 定义为感知到的时长,并指出零弹跳对应临界阻尼,正弹跳值则会让弹簧越来越有弹性。弹簧配置
感知时长并不保证所有可见运动都恰好在某个时间点结束。Apple 将其与取决于弹簧运动的稳定时长区分开来。对于此示例的返回阶段,应使用完成回调,而不是把 0.45 当作计时器。使用弹簧创建动画
如果释放时看起来没有继承运动,先检查交接过程:手势是否改变了弹簧动画所作用的同一个偏移量?是否有其他代码在释放前覆盖了该偏移量?视图是否被替换?是否有动画修饰符在为拖动更新添加动画?这些检查直接对应于文档所述的要求:先跟踪发生变化的可动画属性,再将其交给动画处理。
什么时候应显式读取速度或使用 AppKit?
DragGesture.Value.velocity 会公开当前拖动速度,而 onEnded 会在成功完成后提供手势的最终值。当速度会影响应用决策时,显式读取它很有用,例如在两个合成的吸附位置之间进行选择。对于此示例中固定的返回目标位置,自动跟踪已能完成动画交接。拖动速度、手势最终值
如果你选择使用显式的弹簧初始速度 API,请先检查速度的表示方式,再传入手势测量值。Apple 的手势驱动动画示例会将速度除以剩余行程距离,转换为单位速度。示例中的计算会将每秒 300 点的速度,在 150 点的剩余行程上转换为每秒 2 个动画距离。剩余距离为零时,也需要明确制定处理策略。此处采用 SwiftUI 自动处理的方式可省去这项计算。增强 UI 动画和转场效果
当现有应用拥有的 AppKit 视图或识别器需要处理交互时,可使用 AppKit。NSPanGestureRecognizer.velocity(in:) 会返回相对于所提供视图的水平和垂直速度,单位为每秒点数。虽然它的 Swift 函数签名接受可选视图,但 Apple 明确指出该参数不能是 nil。AppKit 平移速度
对于 macOS 26 及更高版本,NSGestureRecognizerRepresentable 是将 AppKit 手势识别器集成到 SwiftUI 层级中的官方方式。SwiftUI 会管理识别器的创建和更新,并提供坐标转换支持。Apple 提醒,识别器底层视图的几何信息可能与 SwiftUI 视图不同,因此跨越这条边界时应使用 representable 提供的转换器。AppKit 手势集成
对于方块交互,让 SwiftUI 手势单独负责拖动更新,并让释放时的弹簧动画作用于已有偏移量。交接正常工作后,在保持相同状态和坐标边界的前提下调整目标位置和弹簧参数。
来源
tracksVelocity — Apple Developer
使用弹簧创建动画 — Apple Developer
withAnimation(:completionCriteria::completion:) — Apple Developer
DragGesture — Apple Developer
translation — Apple Developer
State — Apple Developer
Properties — Swift.org
onEnded(_:) — Apple Developer
GestureState — Apple Developer
allowsHitTesting(_:) — Apple Developer
removed — Apple Developer
spring(duration:bounce:blendDuration:) — Apple Developer
velocity — Apple Developer
增强 UI 动画和转场效果 — Apple Developer
velocity(in:) — Apple Developer
NSGestureRecognizerRepresentable — Apple Developer