An AnimationController produces a number that moves from 0 to 1 over a duration, advanced once per frame by a Ticker; a Tween maps that 0..1 onto the range you need, and the result is an Animation<T> that transition widgets listen to and redraw from.
**Animation<T>**: a Listenable with a current value and a status; it knows nothing about time
**AnimationController**: the Animation<double> you drive with forward(), reverse(), repeat() and stop(); you create it, you dispose it
**Ticker**: calls the controller once per frame while Flutter is drawing; vsync: this plus SingleTickerProviderStateMixin supplies it
**Tween<T>**: begin and end; tween.animate(controller) returns an Animation<T>, and a Curve in between changes the pace, not the range
Statuses: forward and reverse while it runs, completed when it stops at the upper bound, dismissed when it stops at the lower bound
FlutterMiddle
Animations & Motion
AnimationController, Ticker, Tween, Curves, AnimatedBuilder, staggered motion, Lottie and Rive
Questions closed
0 / 21
- 0Review
- 4Asked often
Questions
You rebuild it with a new property value; it compares that value with the previous one and animates from what is on screen now to the new target over its duration and curve, with a controller it creates and disposes itself.
Trigger: only a changed value starts motion: setState with a new width, color or opacity; rebuilding with the same values does nothing
Change mid-flight: a new target restarts from the current on-screen value, so nothing jumps
The family: AnimatedContainer, AnimatedOpacity, AnimatedPadding, AnimatedAlign, AnimatedPositioned, AnimatedScale, AnimatedSlide, AnimatedDefaultTextStyle; onEnd fires when it settles
Limits: no loop, no reverse on command, no sequence; for those you need an explicit AnimationController
The controller is created in build and never disposed. A test that tears the tree down while it still runs fails with "was disposed with an active Ticker"; a second build already throws "multiple tickers were created", and with TickerProviderStateMixin every rebuild would leak another controller.
Create it once: in initState or a late final field
Dispose it: _controller.dispose() in dispose(), before super.dispose()
Parent changes: when widget.duration changes, assign _controller.duration in didUpdateWidget; recreating the controller would drop the running animation
vsync supplies the Ticker that advances the controller once per frame and mutes it when its subtree is hidden. SingleTickerProviderStateMixin hands out exactly one ticker; a State with two or more controllers needs TickerProviderStateMixin.
**Not a Timer**: a ticker fires only when Flutter is about to draw a frame, in step with the display (60, 90 or 120 Hz); a Timer drifts against frames and keeps firing when nothing is visible
Muting: tickers created by a State follow TickerMode, so a subtree under TickerMode(enabled: false), such as a page beneath an opaque route, stops requesting frames
Wrong mixin: a second controller on the single mixin fails with "is a SingleTickerProviderStateMixin but multiple tickers were created"
Why two mixins: the single one is a little cheaper, so it is the default; switch only when the State really owns several controllers
It prints forward, then forward animating=false, then reverse, and about 200 ms later dismissed. Status listeners fire only when the status changes, and stop() freezes the value without changing the status.
**forward() / reverse()**: switch the status to forward / reverse at once and end in completed (upper bound) or dismissed (lower bound)
**stop()**: keeps value and status and fires no listener; only isAnimating becomes false. reset() is what returns the value to 0 with dismissed
**reverse() from the middle**: covers only the remaining distance, so here it takes about half the duration (of reverseDuration, if set)
**repeat(reverse: true)**: never stops at an end: the status alternates between forward and reverse and never becomes completed, so an "on completed" branch behind a loop never runs
forward() runs the controller toward its upper bound from the current value. After the first tap it is already completed at 1.0, so every later forward() has nothing to do and the heart never pops again.
Why it hides: forward() means "go to 1.0", not "play"; the TweenSequence ends at scale 1.0, so a controller stuck at its end looks exactly like one at rest
The fix: forward(from: 0) resets the value and replays the whole pop; a fast second tap restarts it mid-flight, which suits a like effect. reset() followed by forward() does the same in two calls
When the way back matters: if the effect should animate out instead of jumping, call reverse() on completed or use repeat(reverse: true) for a loop; a single forward() alone is a one-shot
Catch it: a widget test that taps twice, then calls pump() followed by pump(const Duration(milliseconds: 150)) and expects the scale above 1.0 after the second tap as well
A Tween is a formula, not an animation: give it begin and end, and transform(t) returns the value at t between them. tween.animate(controller) returns an Animation<T> that runs the controller's current value through the tween every time you read .value.
The base Tween computes begin + (end - begin) * t, so it works for double and Offset, types with +, - and *. Other types need a subclass that calls the type's own lerp: ColorTween, TextStyleTween, RectTween, AlignmentTween, BorderRadiusTween, and IntTween, which rounds, for int.
**animate() stores nothing: the returned animation keeps the parent and the tween and recomputes on demand, so one tween can feed many animations
controller.drive(tween): the same thing written from the controller's side
chain()**: tween.chain(CurveTween(curve: …)) applies the curve first, then the tween; it adds no listener, so there is nothing to dispose
Common mistake: Tween<Color> throws "Cannot lerp between …"; use ColorTween, whose value is Color?
Curves.easeOutBack and the elastic curves deliberately go past 1 before settling (the in-curves also dip below 0), so downstream tweens receive values like 1.09. That is fine for a scale or a slide, but Opacity asserts that its value is within 0..1.
Keep the overshoot where it is allowed: ScaleTransition, SlideTransition, rotation; drive the opacity from a second curve that stays in 0..1. Two curves off one controller is normal
**FadeTransition: clamps instead of asserting, so the overshoot is not an error there, just lost
CurvedAnimation**: an Animation<double> you own: it can take a different reverseCurve for the way back, and several tweens can share it. It listens to its parent, so dispose it together with the controller
**The curve: argument** of AnimatedContainer and friends applies only to that widget's private controller: one curve, always run forward toward the new target, shared with nothing
Subclass Curve and override transformInternal(double t) to map time 0..1 to progress. A curve must return exactly 0 at the start and 1 at the end (Curve.transform enforces that for you), so your function only shapes what happens in between.
A designer's cubic-bezier: needs no subclass: Cubic(0.2, 0.0, 0.0, 1.0) takes the same four numbers as CSS cubic-bezier()
In between, anything goes: values may leave 0..1 for anticipation or overshoot, with the same Opacity caveat as Curves.easeOutBack
Compose before you write: Interval delays and compresses a curve, Threshold jumps at one point, SawTooth repeats, curve.flipped mirrors it for the way back
**Keep it pure and const**: transformInternal runs every frame for every animation that uses it: no state, no allocation
**Used wherever a Curve is**: CurvedAnimation, CurveTween, the curve: of AnimatedContainer
All three listen to an Animation and redraw on its ticks, so you never call setState yourself. Reach first for a built-in *Transition, use AnimatedBuilder for a one-off effect, and subclass AnimatedWidget when the same effect is reused as a named component.
***Transition widgets**: FadeTransition, ScaleTransition, SlideTransition, RotationTransition, SizeTransition, DecoratedBoxTransition; they rebuild only themselves, and FadeTransition not even that (its render object listens to the animation directly)
**AnimatedBuilder(animation:, builder:, child:)**: inline, for an effect no transition covers (a 3D flip, a colour on a CustomPaint); it takes any Listenable, so it also works with a ValueNotifier or a ScrollController
**The child parameter**: the part that does not depend on the animation is built once and handed to builder on every tick; leave it out and that subtree rebuilds 60–120 times a second
**AnimatedWidget**: subclass it, pass the animation as listenable, read it in build: the same mechanism as AnimatedBuilder, packaged for reuse
Common mistake: controller.addListener(() => setState(() {})), which rebuilds the whole State every frame instead of just the animated part
forward() and reverse() return a TickerFuture that completes only if the animation reaches its end. If something interrupts it (stop(), a call in the other direction, dispose()), the future never completes: a plain await never resumes, so the code after it, a finally, and any caller awaiting your method never run.
**await _c.reverse().orCancel**: throws TickerCanceled on interruption, so you can treat it as "the user changed their mind"
**whenCompleteOrCancel: a callback that runs however the animation ends, for cleanup
Status listener**: addStatusListener fires on every transition; check for completed or dismissed. It lives as long as the controller, so it suits a reaction on every play
When to use which: one-shot exit: await … .orCancel; recurring reaction: a status listener
**After the await**: check mounted before using context or state; the screen may already be gone
onWrongPin adds a new status listener on every call and never removes it. Listeners live as long as the controller, so the Nth shake has N listeners and onShaken (the analytics event, the input reset) runs N times when it completes.
Why it happens: addStatusListener appends to a list and does not deduplicate; each call creates a new closure, so the same code is a new listener every time. It is also a slow leak for the life of the screen
The fix: register the listener once, where the controller is created (or in initState); the method only starts the animation. The reaction then fires exactly once per completed shake
Rule of thumb: addListener or addStatusListener outside initState needs a matching removeStatusListener with the same closure reference; for a reaction to one particular run, await _shake.forward(from: 0).orCancel leaves nothing to unregister
Catch it: a widget test that calls onWrongPin three times with pumpAndSettle after each and expects the callback count to be 3, not 6
One controller: a stagger is a single timeline, the controller is that timeline, and each Interval says which slice of it a property owns. One reverse() then plays the whole sequence backwards from wherever it was interrupted.
**How Interval works**: Interval(begin, end, curve: …) is a Curve that returns 0 before begin, 1 after end, and runs its inner curve in between
Fractions, not milliseconds: three 300 ms steps in a 900 ms controller are Interval(0.0, 0.33), (0.33, 0.66), (0.66, 1.0); a gap is a pause, an overlap blends two motions
Changing the total duration: every slice scales with it, so the rhythm stays the same
When separate controllers are right: the motions have independent lifetimes, like a looping spinner beside a sliding sheet
It takes over your tween: on every new end it sets begin to the value currently on screen, end to the new target, and restarts its internal controller from 0. The badge always continues from where it is, but every retarget costs the full duration again.
**Only end is compared**: a new end starts an animation, an unchanged one starts nothing
**begin matters only on the first build**: if it is null the widget copies end into it and nothing animates on mount; pass one when you want an entrance
The tween is mutated: the widget keeps and edits the first instance it got, so never share one tween between two builders
The retarget trap: position stays continuous, speed does not: the curve starts over at each retarget, so rapid updates look jerky; for a value that changes constantly, drive your own controller with a spring
**child**: pass the static part as child so it is not rebuilt on every tick
AnimatedSwitcher decides "this is a new child" with Widget.canUpdate: same runtime type and same key means the same widget. Two Text widgets with different strings and no key look identical to it, so it updates the text in place with no animation.
Give the child a key: ValueKey(value) on whatever should restart the motion; without it the swap is a silent no-op
Both children exist during the swap: the outgoing one runs backwards with switchOutCurve, the incoming one forwards with switchInCurve
**transitionBuilder(child, animation)**: wraps each child (the default is FadeTransition), so it must look right in both directions
**layoutBuilder**: positions the children that coexist; the default centred Stack makes the switcher as big as the largest child
Because the deleted row must stay on screen while it animates out, and your data no longer holds it, so AnimatedList asks you to hand the widget back through the builder you pass to removeItem.
AnimatedList never diffs your list: it keeps its own item count, and you report every change through AnimatedListState.
Removal: capture the item, remove it from your list and call removeItem(index, (context, animation) => …) in the same synchronous step; the builder closes over the captured item, and its animation runs 1 → 0
Insertion: add to your list, then call insertItem(index); that row's animation runs 0 → 1
Several at once: remove from the highest index down, or the indexes shift under you
Common mistake: setState(() => items.removeAt(i)) without removeItem: the counts disagree and itemBuilder is asked for an index that no longer exists
Lottie plays a fixed After Effects timeline you start, loop or seek; Rive runs a state machine whose inputs you set from Dart, so it suits animations that react to input; hand-built Flutter motion is ordinary code that follows your theme and app state and is easy to test.
Lottie (lottie package): an After Effects export drawn as vector paths every frame; at runtime you can play, loop or seek it with your own AnimationController, and the file decides the duration
Rive (rive package): a .riv file with artboards and a state machine; Dart sets its inputs or data-bound values and the machine picks the transition, so taps, hover and progress are built in; files are often smaller than the same Lottie
Hand-built: AnimationController plus transition widgets or a CustomPainter: no asset, theme colours and RTL for free, covered by widget tests; costs rounds of back-and-forth with the designer
Cost on weak devices: a complex Lottie is CPU-heavy every frame (renderCache trades memory for CPU); profile it on a low-end Android phone before shipping
Let layout produce the numbers and animate the container instead of the value: AnimatedSize measures its child, and when that measurement changes it animates its own size from the old one to the new one; you never write a begin or an end.
**AnimatedSize**: give it duration, curve and alignment; its own size changes every frame, so its parent is laid out every frame: wrap the card, not the page, and give it loose constraints, because in a tight box nothing can change size
When only positions move: a Flow with FlowDelegate(repaint: animation) lays children out once and moves them in paintChildren; the animation touches paint only, so a fan-out menu is much cheaper
When neither fits: a custom RenderObject: listen to the animation, call markNeedsLayout on each tick and read animation.value inside performLayout
Rule: animate at the cheapest pipeline phase that can express the effect: paint before layout, layout before build
Extend ImplicitlyAnimatedWidget, put its State on AnimatedWidgetBaseState, and implement one method, forEachTween: you hand the framework each tween you keep and store back the one it returns.
In exchange you get duration, curve and onEnd on the constructor, a ready curved animation, a setState on every tick, and no controller to dispose.
When it runs: from initState, and from didUpdateWidget to check whether any target changed
The call: _t = visitor(_t, widget.value, (v) => Tween<T>(begin: v as T)) as Tween<T>?, once per animated property; the constructor callback fires only when the tween is still null
What the framework then does: sets begin to tween.evaluate(animation) (what is on screen now), end to the new target, and restarts its controller from 0
Visit every property on every call: a skipped one keeps its stale end and silently stops animating
Types without arithmetic: use the matching tween (ColorTween, EdgeInsetsTween), exactly as with explicit animations
While the finger is down you write controller.value yourself on every drag update; when it lifts you pass the gesture's velocity to controller.fling(velocity: …), and the controller finishes the motion with a spring at the speed the thumb left behind.
**onVerticalDragUpdate**: controller.value -= details.primaryDelta! / extent; the setter stops any running animation and clamps to the bounds
**onVerticalDragEnd**: convert to controller units: -details.velocity.pixelsPerSecond.dy / extent is sheet heights per second; fling springs toward 1 for a positive velocity and toward 0 for a negative one
Slow release: below a velocity threshold, fling toward the nearer end with a synthetic ±1 so the sheet still settles
Common mistake: feeding raw pixels per second to a 0..1 controller (every release looks instant), or calling animateTo on release (it ignores the release velocity, so the sheet jerks to a fixed-speed motion)
Check the built-ins first: DraggableScrollableSheet and showModalBottomSheet already do this; hand-wire it for a gesture they do not cover
A curve has a fixed shape and a fixed duration, so it cannot use the speed the card already has; a Simulation starts from the real velocity and physical constants, and its duration falls out of the physics. "Feels dead" means the card ignored the speed it was released with.
controller.animateWith(simulation) ticks it every frame: value = simulation.x(t) until simulation.isDone(t).
**SpringSimulation(spring, start, end, velocity)**: spring is a SpringDescription: a damping ratio below 1 overshoots and settles, 1 is critically damped; SpringDescription.withDurationAndBounce takes the duration-and-bounce numbers designers use
**FrictionSimulation(drag, position, velocity): momentum with no target, the way a flung list coasts to a stop
Common mistake**: a default controller clamps to 0..1 and flattens the overshoot you chose the spring for; use AnimationController.unbounded
When to use which: continuing a gesture or retargeting mid-flight: a simulation seeded with the current velocity; starting from rest on a tap: a curve is simpler