You cannot fade out something that has already left the composition

An element removed by an if is gone immediately, so an exit animation has nothing to animate. AnimatedVisibility keeps it composed until the exit finishes — and AnimatedContent does the same for swaps.

5 min read
androidcomposekotlinanimation

Day 64 — You cannot fade out what has already left

Day 64 of 100. The animation problem that value-based APIs structurally cannot solve, and the two components that exist because of it.

The symptom

A banner that fades in and vanishes instantly:

val alpha by animateFloatAsState(if (showBanner) 1f else 0f, label = "alpha")

if (showBanner) {
    Banner(Modifier.graphicsLayer { this.alpha = alpha })
}

Appearing works — the composable enters, alpha animates 0 → 1. Disappearing doesn't: the moment showBanner becomes false, the if stops emitting Banner, so it leaves the composition immediately. The alpha animation has nothing left to animate.

Day 8 named the mechanism: leaving the composition is not something a value can defer.

Why the obvious fix fails

The obvious fix is to keep it composed and rely on alpha:

Banner(Modifier.graphicsLayer { this.alpha = alpha })      // always composed

It fades correctly and the banner is still there — occupying layout space, receiving touches, and read aloud by screen readers. An invisible element that intercepts taps is a worse bug than one that vanishes abruptly.

Adding if (alpha > 0f) around it brings back the original problem, one frame later.

An if removes the element immediately; AnimatedVisibility defers removal until the exit finishes

The actual mechanism

AnimatedVisibility owns the composition lifecycle:

AnimatedVisibility(
    visible = showBanner,
    enter = fadeIn() + slideInVertically { -it },
    exit = fadeOut() + slideOutVertically { -it },
) {
    Banner()
}

When visible becomes false it keeps the content composed, runs the exit animation, and only then removes it. That deferral is the entire reason the component exists, and it's not something you can implement with a value.

Enter and exit transitions compose with +:

fadeIn() + expandVertically()
slideInHorizontally { it } + scaleIn()

The available pieces are fadeIn/fadeOut, slideIn/slideOut (and the axis-specific variants), expandIn/shrinkOut (which animate the size, affecting layout), and scaleIn/scaleOut (which don't).

The distinction between expand and scale is the one that matters: expandVertically changes the space the element occupies, so siblings move; scaleIn changes only the drawing, so the layout is reserved from the start. For a list item appearing, you want expand; for a dialog, scale.

Lazy list items get their own modifier

A related case the component doesn't cover: items appearing, disappearing and reordering inside a LazyColumn. AnimatedVisibility around an item animates its visibility, and does nothing about the items below it moving up.

LazyColumn {
    items(messages, key = { it.id }) { message ->
        MessageRow(message, Modifier.animateItem())
    }
}

Modifier.animateItem() animates placement, appearance and removal as the list changes. It requires the key from Day 23 — without stable identity the list can't tell a move from a replacement, which is the same requirement in a new costume.

That single modifier is what makes an inbox feel alive when a message arrives, and it's routinely missed because the list already works without it.

MutableTransitionState, for entrance and completion

Two things the boolean form can't do: animate on first composition, and tell you when the exit finished.

val state = remember { MutableTransitionState(false).apply { targetState = true } }

AnimatedVisibility(visibleState = state, enter = fadeIn(), exit = fadeOut()) {
    Banner()
}

LaunchedEffect(state.isIdle, state.currentState) {
    if (state.isIdle && !state.currentState) onBannerFullyRemoved()
}

isIdle means no animation is running; currentState is the settled value. The pair answers "is it fully gone yet", which matters when the removal should trigger something — releasing a resource, or advancing a queue of messages.

AnimatedContent, for swaps

When content is replaced rather than shown or hidden:

AnimatedContent(
    targetState = count,
    transitionSpec = {
        if (targetState > initialState) {
            slideInVertically { it } + fadeIn() togetherWith
                slideOutVertically { -it } + fadeOut()
        } else {
            slideInVertically { -it } + fadeIn() togetherWith
                slideOutVertically { it } + fadeOut()
        }
    },
    label = "count",
) { targetCount ->
    Text("$targetCount", style = MaterialTheme.typography.headlineLarge)
}

Both the outgoing and incoming content are composed simultaneously during the transition — togetherWith pairs the enter and exit. The direction check is what makes an incrementing counter roll up and a decrementing one roll down, which is a small detail that reads as polish.

Two things to get right:

Use the lambda parameter, not the outer state. The content lambda receives targetCount; using the captured count instead means the outgoing copy renders the new value while animating out, which looks like a glitch.

SizeTransform controls the container. By default the container animates between the two content sizes. SizeTransform(clip = false) stops it clipping during the change, and null disables the size animation entirely.

Nesting inside a transition

Both components have Transition extension forms — Day 63's point. Inside a larger state change, use those so the timing is shared:

transition.AnimatedVisibility(visible = { it != State.Loading }) { Content() }

The standalone versions run their own clock, so an element appearing as part of a screen transition would animate independently of everything else in it.

The accessibility consequence

Worth stating because it's the reason to prefer these over the always-composed hack: when AnimatedVisibility removes content, it leaves the semantics tree too. A screen reader stops seeing it, focus can't land on it, and tests stop finding it.

The alpha-only version keeps all of that — an invisible node that is focusable, findable and tappable. That's not a subtle difference; it's a screen a keyboard user can get stuck in.

How to prove it

The exit-animation claim is directly testable:

@Test fun bannerAnimatesOut() = runComposeUiTest {
    mainClock.autoAdvance = false
    var visible by mutableStateOf(true)
    setContent { AnimatedVisibility(visible) { Banner(Modifier.testTag("banner")) } }

    visible = false
    mainClock.advanceTimeBy(50)
    onNodeWithTag("banner").assertExists()      // still there, animating out

    mainClock.advanceTimeBy(1000)
    onNodeWithTag("banner").assertDoesNotExist()  // now gone
}

Against the if version, the first assertion fails immediately — which is the bug, made into a test. It's a rare case where the failing test is more legible than a description of the problem: the node is gone at 50ms, and it should not be.

mainClock.autoAdvance = false is doing the work here, and it's tomorrow's subject in full.

What this generalizes to

The principle: an element's presence is itself animatable state, and only something that owns the lifecycle can animate it. Values can animate properties of things that exist. Existence is a different question, and it needs a component that can hold the element past the moment your state says it's gone.

Every declarative UI framework grows this component — React has transition groups, Vue has <Transition>, SwiftUI has transitions on conditional views — and always for the same reason. Removal is instant, and animation takes time.

The general form of the lesson is that declarative systems need an explicit place to put "not yet". Anywhere state says something is gone but the interface needs a moment to catch up — an exiting element, a closing sheet, a dismissed notification — something has to own that interval. Making it a component rather than a flag is what keeps it from leaking into every call site.

Tomorrow, Day 65: shared element transitions — animating an element between two screens.


Day 64 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: AnimatedVisibility.