skip to content

Why does Flutter's AnimatedSwitcher sometimes not animate when its child changes, and how do keys fix it?

level: middleimportance: should knowfreq 45%

answer

  1. same type, no key: an update
  2. Widget.canUpdate decides
  3. ValueKey on the child
  4. default FadeTransition in a centred Stack
  5. old child fades out beside new

basics

~20 s

AnimatedSwitcher only transitions when the new child cannot update the old one: a different runtimeType or key. Text('1') to Text('2') updates in place; a ValueKey of the displayed value makes each value a new child.

solid answer

~40 s

`AnimatedSwitcher` keeps the outgoing child alive while the incoming one animates in. It decides that the child changed with `Widget.canUpdate`: same `runtimeType` and same `key` means it is the same child being updated, so the switcher just rebuilds it and plays nothing. Swapping `Text('1')` for `Text('2')` is exactly that case. Give the child a key derived from what it shows, such as `Text('$count', key: ValueKey<int>(count))`, and each value becomes a distinct child that fades out while the next fades in. The defaults: a required `duration`, `switchInCurve` and `switchOutCurve` of `Curves.linear`, a `transitionBuilder` that wraps each child in a `FadeTransition`, and a `layoutBuilder` that stacks old and new children centred in a `Stack`. Swapping in a `null` child, or back, also counts as a change.

code

dart · 24 lines
dart
import 'package:flutter/material.dart';

Widget articleBody({required bool loading, required String? article}) {
  return AnimatedSwitcher(
    duration: const Duration(milliseconds: 300),
    switchInCurve: Curves.easeOut,
    switchOutCurve: Curves.easeIn,
    transitionBuilder: (Widget child, Animation<double> animation) {
      return FadeTransition(
        opacity: animation,
        child: ScaleTransition(scale: animation, child: child),
      );
    },
    layoutBuilder: (Widget? current, List<Widget> previous) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[...previous, if (current != null) current],
      );
    },
    child: loading
        ? const CircularProgressIndicator(key: ValueKey<String>('loading'))
        : Text(article ?? '', key: const ValueKey<String>('loaded')),
  );
}

go deeper

for a junior

Recall that AnimatedSwitcher needs a key on its child to notice a change of the same widget type.

for a middle

Explain Widget.canUpdate, the default FadeTransition and centred Stack, and how switchInCurve and switchOutCurve apply.

for a senior

Pick keys that match the change you want to animate, avoid UniqueKey in build, and tune layoutBuilder to stop layout shifts.

for a principal

Set conventions for state-to-state transitions so screens animate content changes consistently without per-screen controllers.

## What AnimatedSwitcher does **`AnimatedSwitcher`** animates the **replacement of one child by another**. When its `child` changes, it keeps the old child on screen running an outgoing transition while the new child runs an incoming one, then drops the old child. It is the implicit way to animate content changing — a counter ticking, a loading spinner turning into a result, one icon turning into another — without a controller of your own. Its parameters: - **`child`** — the current child; may be null. - **`duration`** (required) and **`reverseDuration`** — how long the incoming and outgoing transitions last; `reverseDuration` defaults to `duration`. - **`switchInCurve`** and **`switchOutCurve`** — both default to `Curves.linear`. - **`transitionBuilder`** — wraps each child with its transition. The default, `AnimatedSwitcher.defaultTransitionBuilder`, returns a `FadeTransition`. - **`layoutBuilder`** — arranges the current and previous children. The default, `AnimatedSwitcher.defaultLayoutBuilder`, puts them in a `Stack` with `Alignment.center`. ## How it decides that the child changed On every rebuild the switcher compares the new `child` with the one it currently shows: 1. If one of them is null and the other is not, that is a change. 2. Otherwise it calls **`Widget.canUpdate(newChild, oldChild)`**, which is true when both have the **same `runtimeType` and the same `key`**. 3. If `canUpdate` is true, the new widget is treated as **an update of the same child**: the switcher rebuilds it in place and plays no transition. 4. If it is false, the new widget is **a different child**: the old one starts its outgoing transition and the new one its incoming transition. | Old child | New child | Animates? | |---|---|---| | `Text('3')` | `Text('4')` | no — same type, no keys | | `Text('3', key: ValueKey(3))` | `Text('4', key: ValueKey(4))` | yes — keys differ | | `CircularProgressIndicator()` | `ArticleView(...)` | yes — types differ | | `Icon(Icons.add)` | `Icon(Icons.close)` | no, unless keyed | | `null` | any widget | yes | This is why the most common bug report is "my AnimatedSwitcher does nothing": the child is the same widget type with no key, so every change is an in-place update. ## Fixing it with keys Give the child a **key that changes whenever its identity should change**, usually a `ValueKey` of the data it displays: ```dart AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: Text( '$helpfulVotes people found this helpful', key: ValueKey<int>(helpfulVotes), ), ) ``` Choose the key deliberately: - **`ValueKey` of the displayed value** — animates exactly when the value changes. - **A key per state** (`ValueKey('loading')`, `ValueKey('loaded')`) — animates between phases but not on small updates within a phase. - **Not `UniqueKey()` created in `build`** — every rebuild then counts as a new child, so unrelated rebuilds replay the transition and the new child's state is thrown away each time. ## Customising the look - **`transitionBuilder`** receives `(Widget child, Animation<double> animation)`; return a `ScaleTransition`, `SlideTransition` or a combination to replace the fade. The same builder is used for both directions, with the animation running in reverse for the outgoing child. - **`layoutBuilder`** receives the current child and the list of previous children. The default centred `Stack` sizes itself to the largest child, which can make surrounding content shift; a `Stack` with `Alignment.topCenter` suits a help-centre article body that grows downwards. - Rapid changes stack several outgoing children at once; each finishes its own outgoing transition. ## Related widgets - **`AnimatedCrossFade`** switches between exactly two fixed children with a `crossFadeState` and animates the size between them; it keeps both children built. - An implicit widget such as `AnimatedOpacity` animates a **property** of one child, whereas `AnimatedSwitcher` animates **replacing** the child. ## Debugging a silent switcher 1. Log the child's `runtimeType` and `key` on each build; if both repeat, the switcher sees an update, not a new child. 2. Check where the key sits. The switcher compares its **direct** child, so a key on a widget nested inside that child changes nothing. 3. Check that the `AnimatedSwitcher` itself survives rebuilds. If it gets a new `State` (a changing key of its own, or a parent that changes type), it has no previous child to fade out. 4. Check `duration`: a zero duration swaps instantly by design.

  • Why is a UniqueKey created inside build a bad fix?
    Every rebuild produces a new key, so `canUpdate` is always false and every rebuild, even one unrelated to the content, replays the transition. The incoming child also gets a fresh `State` each time, losing scroll position, text input or animation state it held.
  • How does AnimatedSwitcher differ from AnimatedCrossFade for two known states?
    `AnimatedCrossFade` takes both children up front and a `crossFadeState` to pick one; it keeps both built and animates the size between them with an internal `AnimatedSize`. `AnimatedSwitcher` takes one child at a time, detects replacement by type or key, and does not animate size by default.

saying these in an interview costs you the question

  • AnimatedSwitcher animates whenever its child's properties change.
  • Wrapping the child in a new Container each build forces a transition.
  • A UniqueKey() in build is the right way to force the animation.
  • The default transition slides the new child in from the side.
  • AnimatedSwitcher needs an AnimationController passed in to run.