在 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 手势单独负责拖动更新,并让释放时的弹簧动画作用于已有偏移量。交接正常工作后,在保持相同状态和坐标边界的前提下调整目标位置和弹簧参数。

来源