易用的动画一直是 SwiftUI 的主要特色之一。但声明式框架的特性是一把双刃剑:动画一旦出现异常,排查往往比命令式框架更棘手。本文记录了过去两周里,我在开发中遇到的两个与 SwiftUI 动画有关的 Bug。与其说是解决方案,不如说是一段排查的心路——希望能为你面对类似问题时开阔一点思路。
Bug 1:显式动画 vs 隐式动画
父视图被意外重建
我的项目中有这样一个列表场景:多个 Cell 依靠状态机实现较为复杂的 UX 效果,视图动画跟随状态机的状态变化而发生。随着场景复杂度不断提高,在 Xcode 26 + iOS 26 中,我偶尔会发现似乎有动画状态被莫名清除。
这种情况出现得非常少,概率大约只有 1~2%,因此很难定位根源,甚至一度让我怀疑是不是自己眼花了。但随着 WWDC 26 的到来,这个问题被彻底放大了——当我在 Xcode 27 + iOS 27 上运行同一套代码时,动画状态被打断的概率几乎超过了 50%。
考虑到相同代码在 macOS 上没有问题,Xcode 27 + iOS 26 的组合也没有明显异常(最多仍是 1~2% 的偶发问题),我最初以为这只是 iOS 27 beta 1 的不稳定。但当问题在 beta 2 中依然存在时,我就不能再坐视不管了。
在用大量埋点观察动画状态后,我将问题锁定为:在关闭动画进行中,SwiftUI 于内部刷新过程中触发了父视图的意外重建,并在原动画事务之外重新提交了子视图的终点状态。由于原来的动画由 withAnimation 所在的 mutation transaction 持有,这次新的非动画更新会让子视图的呈现值直接跳到终点,从而中断在飞动画。
也就是说,我的代码在运行过程中并没有主动执行任何会重建父视图的操作;但由于场景较为复杂,SwiftUI 在某些条件下会自动重建父视图。在 iOS 26 下,对应 SDK 触发这种重建的次数很少;而 iOS 27 似乎大幅调整了 SwiftUI 的内部实现,使这种重建变得更频繁,问题也因此被显著放大。
为什么使用显式动画
我自己也写过不少文章,推荐在合适的情况下优先使用隐式动画。但在我面临的场景中,动画曲线需要根据手势信息动态合成;更关键的是,动画完成后必须立刻执行下一步的状态收尾逻辑,因此 withAnimation 提供的 completion 闭包对我来说至关重要。
不过在上述问题出现后,我不得不重新权衡:是否应该放弃这条路径,让动画声明更靠近动画真正发生的视图位置,从而避免它被系统内部刷新意外中断。
通过 Animatable 构建用于隐式动画的 completion 机制
动画声明越靠近动画的发生地,它受视图树其他部分影响的概率就越低。如今最受推荐的做法,无疑是使用基于闭包的动画范围限定:
.animation(.smooth) {
$0.foregroundStyle(isActive ? Color.red : Color.blue)
}
但对我的场景来说,状态都由状态机维护,因此只需采用与特定值绑定的动画声明方式:
子视图(动画发生地)
.animation(animation, value: closeToken)
将 withAnimation 改成上述基于值的隐式动画后,动画中断问题确实解决了。但随之而来的新问题是:原本依赖 completion 闭包的后续逻辑失去了落脚点。
最终,我通过 Animatable 协议构建了一个 modifier 来弥补这一环。
struct SwipeCellPinnedCloseAnimationModifier: ViewModifier {
let token: Int
let animation: Animation
let onCompletion: (Int) -> Void
func body(content: Content) -> some View {
content
.modifier(
SwipeCellPinnedCloseCompletionObserver(
token: token,
onCompletion: onCompletion
)
)
.animation(animation, value: token)
}
}
private struct SwipeCellPinnedCloseCompletionObserver: ViewModifier, Animatable {
let token: Int
let onCompletion: (Int) -> Void
var progress: CGFloat
init(token: Int, onCompletion: @escaping (Int) -> Void) {
self.token = token
self.onCompletion = onCompletion
self.progress = CGFloat(token)
}
var animatableData: CGFloat {
get { progress }
set {
progress = newValue
notifyCompletionIfNeeded()
}
}
func body(content: Content) -> some View {
content
}
private func notifyCompletionIfNeeded() {
guard token > 0 else { return }
guard abs(progress - CGFloat(token)) < 0.0001 else { return }
DispatchQueue.main.async {
onCompletion(token) // 在状态机中重置 token 为 0, 确保只调用一次
}
}
}
.animation(_:value:) 解决了动画的归属问题,Animatable 则用来实现 completion 逻辑:当插值进度抵达当前 token 时,再把 token 交回状态机,由状态机完成校验并执行真正的收尾工作。
💡 关于
DispatchQueue.main.async:在 Swift Concurrency 普及的今天,很多人倾向于用
Task { @MainActor in ... }或MainActor.run来替代DispatchQueue。但在当前场景下,animatableData的set是在 SwiftUI 的渲染与布局周期内部被调用的,这两个替代方案都不合适。
MainActor.run在调用方已处于主线程时,可能会同步执行,无法跳出当前的计算周期;Task { @MainActor in ... }虽然总是异步执行,但它走的是 Swift Concurrency 的协作式调度,执行时机由 executor 决定,并不保证落在主运行循环(Run Loop)的下一拍。而我们要做的,恰恰是把状态变更(重置 Token)明确推迟到下一个 tick,以稳妥避开 “Publishing changes from within view updates is not allowed” 这一经典运行时警告。在这种特定场景下,
DispatchQueue.main.async是最直接、语义最贴切的工具。
现在,动画曲线以及驱动动画状态的数据,都由状态机统一提供:
子视图
.modifier(
SwipeCellPinnedCloseAnimationModifier(
token: coordinator.closeToken,
animation: inputs.settlingAnimation,
onCompletion: { token in
coordinator.completePinnedClose(token: token)
}
)
)
如此一来,无论是 Xcode 27 + iOS 27,还是 Xcode 26 + iOS 26,动画意外消失的问题都得到了彻底解决。
Bug 2:’?:’ vs ‘if else’
解决了上述 Bug 后,我判断 Xcode 27 + iOS 27 这套组合或许更容易暴露潜在问题,于是在后续开发中让它们成为我的主要搭档。直到完成一个新的动画效果后,剧情却发生了反转——这一次,代码运行在 iOS 26 上反而出现了动画异常中断。
有了上次的经验,我首先怀疑问题仍出在动画声明上。但检查后发现,并非如此。
最终,通过注释大法,我把问题锁定在如下代码中:
Image(systemName: context.isArmed ? "ellipsis" : "heart")
当包含这段代码的 Cell 正在进行动画、且 context.isArmed 发生变化时,List 中的这个 Image 会出现异常,进而中断整个 Cell 的动画。
解决方案简单粗暴:
if context.isArmed {
Image(systemName: "ellipsis")
} else {
Image(systemName: "heart")
}
这个修复并非因为 if else 本身更神奇,而是它改变了视图的结构性标识:三元表达式让 SwiftUI 复用同一个 Image 节点、原地切换 SF Symbol 名称;而 if/else 会生成 _ConditionalContent 的两个不同分支,让 SwiftUI 看到两个互不相同的 Image。
反直觉之处恰在于此:我们平时反复强调“保持视图标识稳定”,但在这个场景里,主动制造出不同的标识,反而让问题消失了。
真正让人头疼的是问题的复现条件——Image + 三元表达式的异常只出现在 iOS 26 及更早版本,并且只出现在 List 场景中。在 ScrollView 中没有问题;在 macOS 上,无论哪种场景、哪个版本都没有问题。
相比 Bug 1,Bug 2 在技术上几乎没有难度。但恰恰因为 Bug 1 的存在,让我一开始又把注意力拉回到动画声明上,白白浪费了一个多小时。
了解原理外,运气也很重要
SwiftUI 的动画系统无疑是强大的,苹果也提供了不少高级工具来帮助开发者解决问题。例如,geometryGroup 可以将动画插值逻辑上提,Animatable 则能显式要求动画插值,并基于这些插值执行额外操作。
但当问题真正发生时,它可能只出现在个别系统版本、个别 SDK、个别设备,或某个非常具体的场景中。解决这类问题,除了理解原理,确实也需要一点运气。
只是运气并非全靠天意。回头看,埋点与注释大法这类笨办法,本质上是在把模糊的“异常气味”逐步逼成可定位的事实——而这种对异常的敏感、以及把直觉转化为方法的能力,或许正是开发者在 AI Agent 时代仍然拥有的特殊优势。