A Flutter implicit animation sometimes jumps straight to its end value instead of animating; what causes that, and how do you diagnose it?
answer
- a fresh State starts at the target
- keys and tree position
- null on either side
- value set twice before a frame
- Slow Animations to watch it
basics
~20 sUsually the widget got a new State, which starts at the target: a changing key, a parent that changes type, or a shifted tree position. Nulls on either side, a zero duration, or a value flipped back before rebuild also snap.
solid answer
~50 sAn implicit widget only animates when the **same** `State` sees its target change in `didUpdateWidget`. A **new** `State` builds at the target with nothing to animate from, so it looks like a snap. That happens when the widget's key changes (a `UniqueKey()` or a key derived from changing data), when a conditional wrapper changes its parent type, or when it moves to another position in its parent's children without a key. Other causes: the property is null before or after, since nulls are not tweened; `duration` is `Duration.zero`, for example behind a reduced-motion flag; the value changes and changes back before the next build, so the target the widget sees never differs. Diagnose by logging in `initState` or watching the widget in DevTools' inspector: a new State per change confirms an identity problem. Slow Animations makes the remaining cases easy to see.
code
dart · 19 linesimport 'package:flutter/material.dart';
class Faq {
const Faq(this.id, this.question, {this.selected = false});
final String id;
final String question;
final bool selected;
}
Widget faqTile(Faq faq) {
return AnimatedContainer(
// Bug: ValueKey('${faq.id}-${faq.selected}') made a new State per toggle.
key: ValueKey<String>(faq.id),
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
color: faq.selected ? Colors.amber.shade100 : Colors.white,
child: Text(faq.question),
);
}go deeper
Recall that implicit animations need the same widget to be rebuilt with a new value, and that null values do not animate.
Explain why a new State starts at its target, and how keys, parent type and sibling position decide whether the State survives.
Diagnose a snap methodically with initState logging, the inspector and Slow Animations, then fix identity rather than timing.
Build review habits, such as no UniqueKey in build and stable list keys, that prevent a class of animation and state-loss bugs.
## Why it snaps: the one rule Every implicit widget (`AnimatedContainer`, `AnimatedOpacity`, `AnimatedAlign`, `TweenAnimationBuilder` and the rest) stores its animation in its **`State`**. It animates only when that **same State** receives a rebuilt widget whose target differs from the tween's end, in `didUpdateWidget`. A **brand-new State** starts with its tween at the first value it sees, so the widget appears **already at the target** — indistinguishable from a snap. Most "it just jumps" bugs are therefore identity bugs, not animation bugs. ## Causes 1. **A key that changes.** `AnimatedContainer(key: UniqueKey(), …)` built in `build`, or `key: ValueKey(item.status)` where status is the very thing that changes, gives Flutter a different identity each time. The old element is discarded, a new State is created, and it starts at the new value. 2. **A parent that changes type.** `isSelected ? Card(child: tile) : tile` puts the animated widget under different parents on alternate builds. When the widget at a position changes type, its whole subtree, including the animated widget's State, is rebuilt from scratch. 3. **A shifting position among siblings.** In a `Column` whose children are added or removed conditionally, an unkeyed animated widget can be matched with a different element, or created fresh, as indices shift. 4. **A null on one side.** Properties that are null are not animated. `height: null` to `height: 200` (or back) applies at once. 5. **A zero or tiny duration.** A `duration` fed from a setting — for example zero when the platform asks apps to reduce motion — animates instantly by design. 6. **A value that returns before the rebuild.** If state is set to the new value and back again before the next frame, the widget is rebuilt with the same target it already has, and nothing plays. 7. **Starting too late.** An implicit widget never animates its first build (except `TweenAnimationBuilder`), so a widget inserted already holding its final value has nothing to animate from. ## Diagnosing | Check | How | What confirms it | |---|---|---| | New State each change | `debugPrint` in the widget's parent State `initState`, or watch the widget in the DevTools inspector | `initState` runs on every toggle | | Key churn | search for `UniqueKey()` and keys built from the changing value | key differs between builds | | Parent type swap | look for `cond ? Wrapper(child: x) : x` | subtree identity changes | | Null target | print the property on each build | `null` on either side | | Duration | print `duration` | `Duration.zero` | | Timing | watch with **Slow Animations** in DevTools | nothing moves even slowed down | The inspector's **Slow animations** toggle runs animations five times slower (the same effect as setting `timeDilation` from `package:flutter/scheduler.dart` in code), so a short but real animation becomes obvious, while a genuine snap still snaps. ## Fixes - **Stable identity.** Remove `UniqueKey()` from `build`; key list items by a stable id (`ValueKey(item.id)`), not by the changing field. - **A fixed tree shape.** Keep the wrapper in place and vary its properties instead — for a selected FAQ card, animate the decoration of a card that is always there rather than wrapping the tile in a `Card` only when selected. - **Non-null endpoints.** Use `0` and a real value, or switch to `AnimatedSize` or `AnimatedCrossFade` when the natural size is unknown. - **Build before the change.** For an entrance effect, build at the start value, then change it on a later frame, or use `TweenAnimationBuilder`, which animates from `begin` on first build. ## A help-centre case The FAQ list highlights the selected card with an `AnimatedContainer`, but the highlight pops on instantly. The list builds `FaqTile(key: ValueKey('${faq.id}-${faq.selected}'))`: the key includes the selection flag, so selecting a card replaces its element and its `AnimatedContainer` State. Keying by `faq.id` alone keeps the State, and the colour fades as intended. ## Preventing it in review - No `UniqueKey()` inside `build`. - List keys come from stable ids, never from a field that changes. - Conditional wrappers around animated widgets are replaced by a wrapper that is always present with varying properties. - Animated properties use non-null values at both ends, or a size-measuring widget when the size is unknown.
- Why does changing only the curve of an implicit widget mid-flight make it jump?The state rebuilds its curved animation around the same controller when the curve changes. The controller's progress is unchanged, but the new curve maps that progress to a different value, so the output moves abruptly to the new curve's position before continuing.
- How can reduced motion be honoured without breaking implicit animations?Feed the widget a shorter or zero duration when the platform asks to reduce motion. A zero duration is an intentional snap, so check that flag before treating an instant change as a bug.
saying these in an interview costs you the question
- A new key makes an AnimatedContainer replay from its old value.
- A snapping animation means the duration is too short to see.
- Implicit widgets animate from null to a value if you give a curve.
- Wrapping conditionally in a Card is harmless to animations below it.
- Keying AnimatedContainer by the toggled value restarts its tween.