When a Flutter onboarding screen brings in three illustrations one after another from a single AnimationController, how do you stagger them with Interval, and what goes wrong?
answer
- one controller, one timeline
- begin and end are fractions
- 0.0 before begin, 1.0 after end
- the curve applies inside the window
- reverse replays the order backwards
basics
~20 sGive each illustration its own tween driven through Interval(begin, end, curve:), whose fractions of the controller's duration decide when it moves. Before begin it reads 0.0 and after end 1.0, so one controller plays, reverses and disposes all three together.
solid answer
~40 sI use one `AnimationController` whose `duration` is the whole entrance, say 1200 ms, and give each illustration its own animation, such as `controller.drive(CurveTween(curve: Interval(0.0, 0.5, curve: Curves.easeOutCubic))).drive(Tween<Offset>(...))`, then `Interval(0.25, 0.75)` and `Interval(0.5, 1.0)`. `Interval` maps the parent's `t` to 0.0 before `begin`, 1.0 after `end`, and applies its own `curve` (default `Curves.linear`) in between, so windows may overlap. The usual bugs: fractions converted from milliseconds against the wrong total, a `begin` or `end` outside 0-1 tripping Interval's asserts, a `CurvedAnimation` per illustration built in `build` and never disposed, and three controllers chained with `Future.delayed` that cannot be reversed or scrubbed as one. Reversing replays the stagger backwards, last illustration first.
code
dart · 64 linesimport 'package:flutter/material.dart';
class OnboardingEntrance extends StatefulWidget {
const OnboardingEntrance({super.key, required this.illustrations});
final List<Widget> illustrations; // exactly three
@override
State<OnboardingEntrance> createState() => _OnboardingEntranceState();
}
class _OnboardingEntranceState extends State<OnboardingEntrance>
with SingleTickerProviderStateMixin {
static const List<Interval> _windows = <Interval>[
Interval(0.0, 0.5, curve: Curves.easeOutCubic),
Interval(0.25, 0.75, curve: Curves.easeOutCubic),
Interval(0.5, 1.0, curve: Curves.easeOutCubic),
];
late final AnimationController _controller;
late final List<Animation<Offset>> _slides;
late final List<Animation<double>> _fades;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_slides = <Animation<Offset>>[
for (final Interval window in _windows)
_controller
.drive(CurveTween(curve: window))
.drive(Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero)),
];
_fades = <Animation<double>>[
for (final Interval window in _windows)
_controller.drive(CurveTween(curve: window)),
];
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
for (var i = 0; i < 3; i++)
FadeTransition(
opacity: _fades[i],
child: SlideTransition(
position: _slides[i],
child: widget.illustrations[i],
),
),
],
);
}
}go deeper
Recall that Interval takes fractions of the controller's duration and that one controller can drive several tweens through different windows.
Explain how Interval rescales and clamps the parent's t, why the windows may overlap, and why CurveTween windows avoid extra disposal.
Diagnose the real failures: millisecond conversions that push end past 1.0, CurvedAnimation built in build, overshoot fed into an Interval, and a reverse that plays the stagger backwards.
Decide when a stagger belongs on one controller and when an exit deserves its own timeline, and how windows should be expressed so retiming stays a one-line change.
## Why one controller A **staggered animation** is a set of motions that start at different moments but belong to one choreography. In Flutter the idiomatic way to build one is a **single `AnimationController`** whose `duration` covers the whole sequence, with each moving element reading its own slice of that timeline. The alternative - one controller per element, started with `Future.delayed` - fails in predictable ways: - three tickers and three disposals instead of one; - a pending delay can fire after the `State` is disposed and call `forward()` on a disposed controller; - the sequence cannot be reversed, stopped, or set to an exact point (`controller.value = 0.4`) as a whole; - a widget test has to advance three independent clocks plus timers. ## How Interval maps time `Interval(begin, end, {curve = Curves.linear})` is a **`Curve`** whose job is to carve a window out of the parent's `0.0`-`1.0` progress. For a parent value `t` it: 1. rescales `t` into the window: `(t - begin) / (end - begin)`; 2. **clamps** the result to `0.0`-`1.0`, so it reads `0.0` before `begin` and `1.0` after `end`; 3. applies its own `curve` to the clamped value (the endpoints pass through unchanged). Its debug asserts require `begin` and `end` to lie within `0.0`-`1.0` and `end >= begin`. `begin` and `end` are **fractions of the controller's duration**, not milliseconds. ## Building the three-illustration entrance ```dart const windows = <Interval>[ Interval(0.0, 0.5, curve: Curves.easeOutCubic), Interval(0.25, 0.75, curve: Curves.easeOutCubic), Interval(0.5, 1.0, curve: Curves.easeOutCubic), ]; _slides = [ for (final Interval window in windows) _controller .drive(CurveTween(curve: window)) .drive(Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero)), ]; ``` Each illustration gets an `Animation<Offset>` (and usually a matching opacity animation on the same window), and a transition widget renders it. Because every animation is derived from the same controller, `forward()`, `reverse()`, `stop()` and `dispose()` act on the whole choreography at once. Using `CurveTween` rather than `CurvedAnimation` for the windows means there is nothing extra to dispose. ## Choosing the windows | Illustration | Interval | Moves during (1200 ms controller) | |---|---|---| | First | `Interval(0.0, 0.5)` | 0-600 ms | | Second | `Interval(0.25, 0.75)` | 300-900 ms | | Third | `Interval(0.5, 1.0)` | 600-1200 ms | Overlap is allowed and is usually what makes a stagger feel continuous: between 300 and 600 ms the first two illustrations move together. Each element's own apparent duration is `(end - begin) * duration`, so these three each move for 600 ms. ## Failure modes to name in an interview - **Milliseconds against the wrong total.** Converting `600-1400 ms` for a 1200 ms controller gives `Interval(0.5, 1.1667)`, which fails the `end <= 1.0` assert on the first mid-animation frame. - **`CurvedAnimation` per window, built in `build`.** Each one adds a status listener to the controller; rebuilding leaks them. Build the animations once in `initState`. - **An overshooting curve on the parent.** Feeding a `Curves.easeOutBack` output into an `Interval` hands it a `t` above `1.0`, and curves assert that their input lies in `0.0`-`1.0`. Put overshoot inside the window's own `curve` instead. - **Expecting the entrance order on reverse.** Running the controller backwards replays the stagger in reverse: the last illustration leaves first. - **Per-window durations hard-coded elsewhere.** If another widget also assumes 600 ms, retiming the controller silently desynchronises them. ## Reverse, replay and retiming To speed the whole entrance up, change only the controller's `duration`: the fractions are relative, so every window shrinks proportionally and the stagger keeps its shape. To replay, call `controller.forward(from: 0.0)`. If the exit must not mirror the entrance, give it its own controller and windows rather than fighting `reverse()`.
- Why not three AnimationControllers started with Future.delayed?Three controllers mean three tickers and three disposals, and the timing lives in timers the controllers know nothing about. A pending `Future.delayed` can fire after `dispose` and call `forward()` on a disposed controller. The sequence also cannot be reversed, stopped or set to an exact point as one unit, whereas `controller.value = 0.4` on a single controller shows exactly that moment of the whole entrance.
- How do you make the second illustration start before the first one finishes?Let the windows overlap: with `Interval(0.0, 0.5)` and `Interval(0.25, 0.75)`, both illustrations move between 25 % and 50 % of the controller's run. `Interval` only rescales and clamps the parent's `t`, so nothing forbids overlap; how much the windows overlap is the choreography you tune.
- The product asks for the whole entrance to run faster. What do you change?Only the controller's `duration`. `Interval` fractions are relative to it, so every window shrinks proportionally and the stagger keeps its shape. Had the offsets been hard-coded in milliseconds, each would need recomputing, and any mistake would push an `end` past 1.0 and trip Interval's assert.
saying these in an interview costs you the question
- Interval takes its begin and end in milliseconds.
- Each staggered element needs its own AnimationController.
- Overlapping Interval windows are invalid and throw.
- Before its begin fraction, an Interval-driven tween already shows its end value.
- Reversing the controller replays the stagger in the original order.