Fix: SwiftUI Removal Transition View Disappears Before It Collapses

Fix: SwiftUI Removal Transition View Disappears Before It Collapses

Written by

in

Quick answer: The removal transition is animating a snapshot, but SwiftUI tears down the live view too early because the ViewModifier’s body returns a single modified view. Adding an empty HStack changes the returned type to a TupleView, which changes the view’s structural identity and dodges that code path. Wrap the content in an explicit container, or better, animate the bar’s height and opacity in place instead of using a removal transition.

Why this happens

A .modifier(active:identity:) removal transition works in two stages: SwiftUI captures the view, removes the live view from the hierarchy, then animates the captured snapshot from the active modifier value to the identity value. The symptom you see — the bar vanishing instantly and then an empty frame collapsing — means the live view was removed before the snapshot took over.

The trigger is the shape of the view tree. ViewModifier.body(content:) is a @ViewBuilder, so the number of statements in it changes the type it returns. With only the modified content expression, the modifier returns that single view. Add HStack {} and the builder returns a TupleView with two children. Same pixels on screen, different static type, different structural identity, and a different internal path through SwiftUI’s diffing and transition code.

So the empty HStack is not fixing anything. It is a hierarchy change that happens to avoid the buggy path. That is also why it looks so arbitrary, and why deleting it later silently reintroduces the problem.

One more detail worth knowing: the if let msgs = messages, show condition removes the entire branch, so the transition runs on the HStack inside it. Nothing outside that branch stays put to anchor the animation.

How to fix it

1. Make the structural change explicit

If you want to keep the transition, replace the accidental empty HStack with a real container so the modifier always returns a container view. This is the same structural change, but readable and intentional:

struct FoldUpModifier: ViewModifier {
    var progress: CGFloat

    func body(content: Content) -> some View {
        VStack(spacing: 0) {
            content
        }
        .frame(height: (1 - progress) * 40, alignment: .top)
        .clipped()
    }
}

Do not leave HStack {} in place with a comment. Anyone tidying the file will delete it and the animation will break again with no obvious cause.

2. Declare the animatable data

A ViewModifier only interpolates its stored values if it conforms to Animatable. Without it, progress jumps between 0 and 1 and you are relying on SwiftUI’s default handling of the frame change. Make it explicit:

struct FoldUpModifier: ViewModifier, Animatable {
    var progress: CGFloat

    var animatableData: CGFloat {
        get { progress }
        set { progress = newValue }
    }

    func body(content: Content) -> some View {
        VStack(spacing: 0) {
            content
        }
        .frame(height: max(0, (1 - progress) * 40), alignment: .top)
        .clipped()
    }
}

The max(0, ...) guard keeps the height from going negative if the animation overshoots with a spring curve.

3. Animate the bar in place instead of removing it (recommended)

Removal transitions depend on snapshot timing, which is exactly the part that misbehaved here. For a collapse effect you do not need a transition at all — keep the view in the hierarchy and animate its height and opacity. There is no snapshot, no teardown, and nothing to race:

struct NoticeMessageBarView: View {
    var messages: [String]?
    @State private var isVisible = true

    var body: some View {
        if let msgs = messages {
            HStack {
                VerticalMessageCarousel(messages: msgs, scrollInterval: 3.5)
                Spacer()
                Button {
                    withAnimation(.easeOut(duration: 0.3)) {
                        isVisible = false
                    }
                } label: {
                    Image(systemName: "xmark")
                }
            }
            .padding(.horizontal, 16)
            .frame(height: isVisible ? 40 : 0, alignment: .top)
            .clipped()
            .opacity(isVisible ? 1 : 0)
        }
    }
}

Two things to note. The if let now only guards against messages being nil, so the bar is never torn down by the animation itself. And the height goes from a concrete 40 to 0 rather than from nil to 0, because nil does not interpolate.

If you need the bar to come back, set isVisible = true inside a withAnimation block and the same code runs in reverse.

4. If you keep the transition, attach it to a stable wrapper

When several views share the same collapse behaviour, put the transition on one container that is the single child of the conditional branch, and let the branch decide whether that container exists:

@ViewBuilder
private var bar: some View {
    if let msgs = messages, show {
        HStack { /* content */ }
            .transition(.asymmetric(
                insertion: .opacity,
                removal: .modifier(
                    active: FoldUpModifier(progress: 1),
                    identity: FoldUpModifier(progress: 0)
                )
            ))
    }
}

The rule of thumb: one conditional, one transitioned container, no extra siblings inside the modifier body.

If the workaround is the only thing that makes the animation correct on your deployment target, file a Feedback report with the minimal example. A hierarchy change that fixes an animation is a strong signal of an implementation bug, and Apple needs the repro to act on it.

How to prevent it

  • Never leave an empty container in a ViewModifier body as a fix. Replace it with a real wrapper or restructure the view.
  • Prefer animating properties (height, opacity, offset, scale) over insertion/removal transitions for collapse and expand effects.
  • Keep the view tree shape stable across states. Branch inside a container rather than around it.
  • Conform custom transition modifiers to Animatable so the interpolation is defined by your code, not inferred.
  • Turn on Debug > Slow Animations in the Simulator. It makes it obvious whether the live view or the snapshot is moving.

FAQ

Why does adding an empty HStack change the animation at all?

Because body(content:) is a @ViewBuilder. The extra statement changes the return type from the modified content to a TupleView, which changes the view’s structural identity and the code path SwiftUI uses when removing it.

Is .modifier(active:identity:) deprecated or broken?

No. It is the supported way to build a custom transition. The problem is the timing of the teardown relative to the snapshot, not the API itself.

Does the same thing happen with insertion transitions?

Less often. Insertion adds a live view rather than removing one, so there is no snapshot handoff to get wrong. The symptom described here — the view disappearing before the collapse finishes — is specific to removal.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.