Easy-to-use animation has always been one of SwiftUI’s defining features. But the nature of a declarative framework is a double-edged sword: once an animation misbehaves, tracking down the cause is often trickier than in an imperative framework. This post records two SwiftUI animation bugs I ran into over the past two weeks—less a set of solutions than an account of the hunt itself, shared in the hope that the thought process proves useful the next time you face something similar.
Bug 1: Explicit Animation vs. Implicit Animation
A Parent View Rebuilt Unexpectedly
My project has a list scenario where several cells rely on a state machine to drive a fairly complex UX, with view animations following the state machine’s transitions. As the scenario grew more complex, I occasionally noticed—on Xcode 26 + iOS 26—what looked like animation state being mysteriously wiped out.
This happened very rarely, with a probability of only around 1–2%, which made it hard to pin down the root cause—at one point I even wondered whether I was just seeing things. But once WWDC 26 arrived, the problem blew wide open: when I ran the same code on Xcode 27 + iOS 27, the probability of an animation being interrupted shot past 50%.
Since the same code had no issues on macOS, and the Xcode 27 + iOS 26 combination showed no obvious anomaly either (still the occasional 1–2% at most), my initial assumption was that this was just iOS 27 beta 1 being unstable. But when the problem persisted into beta 2, I could no longer let it slide.
After observing the animation state with a great deal of instrumentation, I narrowed the problem down to this: while the closing animation was in progress, SwiftUI triggered an unexpected rebuild of the parent view during an internal refresh, and re-committed the child view’s terminal state outside the original animation transaction. Because the original animation was held by the mutation transaction that withAnimation lived in, this new, non-animated update snapped the child view’s presented value straight to its endpoint, cutting off the in-flight animation.
In other words, nothing in my code actively did anything that would rebuild the parent view. But because the scenario was complex, SwiftUI would, under certain conditions, rebuild the parent view on its own. On iOS 26, the corresponding SDK triggered this rebuild rarely; iOS 27, on the other hand, seems to have substantially reworked SwiftUI’s internals, making the rebuild far more frequent and thereby amplifying the problem dramatically.
Why Use Explicit Animation
I’ve written my fair share of articles recommending implicit animation where appropriate. But in the scenario I was facing, the animation curve needs to be synthesized dynamically from gesture information; more importantly, once the animation finishes, the next state-finalization step has to run immediately. That makes the completion closure offered by withAnimation essential for me.
After the problem above surfaced, though, I had to reconsider: should I abandon this path and move the animation declaration closer to the view where the animation actually happens, so it can’t be interrupted by SwiftUI’s internal refreshes?
Building a completion Mechanism for Implicit Animation via Animatable
The closer an animation declaration sits to where the animation actually happens, the less likely it is to be affected by other parts of the view tree. The most recommended approach today is undoubtedly the closure-based animation scope:
.animation(.smooth) {
$0.foregroundStyle(isActive ? Color.red : Color.blue)
}
But in my case, all state is maintained by the state machine, so I only needed the value-bound form of the animation declaration:
ChildView // where the animation happens
.animation(animation, value: closeToken)
After switching from withAnimation to the value-based implicit animation above, the interruption problem was indeed resolved. But a new problem came with it: the follow-up logic that used to depend on the completion closure had lost its footing.
In the end, I built a modifier using the Animatable protocol to fill that gap.
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) // resets token to 0 in the state machine, ensuring this fires only once
}
}
}
.animation(_:value:) solves the question of where the animation belongs; having the ViewModifier also conform to Animatable is what implements the completion logic: when the interpolation progress reaches the current token, the token is handed back to the state machine, which validates it and runs the real finalization work.
💡 On
DispatchQueue.main.async:In an era where Swift Concurrency has become widespread, many people are inclined to use
Task { @MainActor in ... }orMainActor.runin place ofDispatchQueue. But in this particular scenario,animatableData’ssetis called from inside SwiftUI’s render-and-layout cycle, and neither alternative fits.
MainActor.run, when the caller is already on the main thread, may execute synchronously, so it cannot break out of the current computation cycle.Task { @MainActor in ... }always runs asynchronously, but it goes through Swift Concurrency’s cooperative scheduling—its execution timing is decided by the executor and is not guaranteed to land on the next turn of the main run loop.What we need is precisely to defer the state change (resetting the token) to the next run-loop tick, so we can reliably steer clear of the classic “Publishing changes from within view updates is not allowed” runtime warning. In this specific scenario,
DispatchQueue.main.asyncis the most direct tool, and the one whose semantics fit best.
Now the animation curve and the data driving the animation state are both supplied uniformly by the state machine:
ChildView
.modifier(
SwipeCellPinnedCloseAnimationModifier(
token: coordinator.closeToken,
animation: inputs.settlingAnimation,
onCompletion: { token in
coordinator.completePinnedClose(token: token)
}
)
)
With that, whether on Xcode 27 + iOS 27 or Xcode 26 + iOS 26, the problem of animations vanishing unexpectedly was completely resolved.
Bug 2: ’?:’ vs. ‘if/else’
Having solved the bug above, I figured the Xcode 27 + iOS 27 combination might be better at exposing latent problems, so I made the two my primary companions for the work that followed. Then, right after I finished a new animation effect, the plot took a twist—this time the animation broke abnormally when the code ran on iOS 26.
With the previous experience fresh in mind, my first suspicion was that the problem still lay in the animation declaration. But after checking, that wasn’t it.
In the end, using the trusty comment-it-out method, I narrowed the problem down to this line:
Image(systemName: context.isArmed ? "ellipsis" : "heart")
When a cell containing this code was animating and context.isArmed changed, the Image inside the List would misbehave, breaking the cell’s animation.
The fix was crude but effective:
if context.isArmed {
Image(systemName: "ellipsis")
} else {
Image(systemName: "heart")
}
This fix works not because if/else is somehow more magical, but because it changes the view’s structural identity: the ternary expression makes SwiftUI reuse a single Image node and swap the SF Symbol name in place, whereas if/else produces two distinct branches of _ConditionalContent, so SwiftUI sees two different Image views.
The counterintuitive part is exactly this: we usually emphasize keeping a view’s identity stable, yet in this scenario, deliberately manufacturing different identities is what made the problem disappear.
What really hurt was the reproduction conditions—the Image + ternary anomaly only shows up on iOS 26 and earlier, and only within a List. There’s no problem in a ScrollView; and on macOS, no scenario and no version exhibits it at all.
Compared with Bug 1, Bug 2 had almost no technical difficulty. But it was precisely because Bug 1 existed that I initially pulled my attention back to the animation declaration, wasting a good hour for nothing.
Beyond Understanding the Mechanics, Luck Matters Too
SwiftUI’s animation system is undeniably powerful, and Apple offers plenty of advanced tools to help developers solve problems. For example, geometryGroup can lift interpolation logic up the tree, while Animatable lets you explicitly request interpolation and run extra work based on those interpolated values.
But when a problem actually strikes, it may only surface on a particular system version, a particular SDK, a particular device, or in one very specific scenario. Solving this kind of problem takes not only an understanding of the mechanics, but, honestly, a bit of luck too.
Except luck isn’t entirely a matter of chance. Looking back, low-tech methods like instrumentation and the comment-it-out approach are, at their core, about squeezing a vague “something smells wrong” down into a locatable fact—and that sensitivity to anomalies, along with the ability to turn intuition into method, may be exactly the edge developers still hold in the age of AI agents.