skip to content

In Flutter, how do TweenSequence and Animatable.chain let one controller drive a multi-stage value such as grow, hold, then shrink?

level: middleimportance: should knowfreq 27%

answer

  1. segments share one timeline
  2. weights are relative shares
  3. ConstantTween holds a value
  4. chain runs its argument first
  5. no intermediate Animation allocated

basics

~10 s

TweenSequence splits one controller's timeline into consecutive segments, each a TweenSequenceItem whose relative weight sets its share. chain composes Animatables, so tween.chain(CurveTween(...)) curves t first and then interpolates, without allocating an intermediate Animation.

solid answer

~40 s

`TweenSequence<T>` takes a list of `TweenSequenceItem(tween:, weight:)`. Weights are relative: each item gets `weight / totalWeight` of the timeline, laid end to end, and the last one ends exactly at 1.0. Inside its slice the parent's `t` is rescaled to 0-1 for that item's tween, so grow-hold-shrink is `Tween(1.0 -> 1.2)` weight 40, `ConstantTween(1.2)` weight 20, `Tween(1.2 -> 1.0)` weight 40. `chain` is function composition: `a.chain(b)` evaluates `b` first and feeds its result into `a`, so `Tween(...).chain(CurveTween(curve: Curves.ease))` eases the progress before interpolating. It returns an `Animatable`, which is why it fits inside a `TweenSequenceItem`. Build the sequence once and reuse it; the framework notes a small construction cost.

code

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

final Animatable<double> pulse = TweenSequence<double>(<TweenSequenceItem<double>>[
  TweenSequenceItem<double>(
    tween: Tween<double>(begin: 1.0, end: 1.2)
        .chain(CurveTween(curve: Curves.easeOut)),
    weight: 40,
  ),
  TweenSequenceItem<double>(tween: ConstantTween<double>(1.2), weight: 20),
  TweenSequenceItem<double>(
    tween: Tween<double>(begin: 1.2, end: 1.0)
        .chain(CurveTween(curve: Curves.easeIn)),
    weight: 40,
  ),
]);

// Inside a State with an AnimationController named _controller:
// late final Animation<double> _scale = _controller.drive(pulse);

go deeper

for a junior

Recall that TweenSequence lets one controller play several stages in a row and that ConstantTween holds a value for a stage.

for a middle

Explain how weights become relative slices, how t is rescaled inside a slice, and that chain runs its argument before the receiver.

for a senior

Spot chain written in the wrong order, overshooting curves feeding a sequence, and sequences rebuilt in build, and choose TweenSequence or Interval for the job at hand.

for a principal

Judge when a multi-stage value belongs in one declarative sequence and when stages deserve separate timelines that product can retime independently.

## One timeline, several stages Some values do not go simply from A to B. A badge that **pulses** grows, holds, then shrinks back; a progress glyph may rise, overshoot and settle. You could run several controllers in a row, but that brings the same problems as any chained-timer choreography: more tickers, more disposals, and no way to reverse or scrub the whole thing. Flutter's answer is to keep **one controller** and describe the stages as **`Animatable`s** that divide its `0.0`-`1.0` progress. Two tools do that: **`TweenSequence`** and **`Animatable.chain`**. ## How TweenSequence divides time `TweenSequence<T>(List<TweenSequenceItem<T>> items)` is itself an `Animatable<T>`. Each `TweenSequenceItem` has a `tween` (any `Animatable<T>`) and a `weight`. When the sequence is built: 1. the weights are summed; each must be greater than `0.0`, and the list must not be empty; 2. each item receives the slice `weight / totalWeight` of the timeline, **in list order, end to end** - there are no gaps and no overlaps; 3. the last slice is pinned to end exactly at `1.0`, so rounding never leaves a hole. When the sequence is evaluated at `t`, it finds the slice containing `t`, rescales `t` into `0.0`-`1.0` **within that slice**, and passes it to that item's tween. The weights are proportions, not percentages: `1, 2, 1` and `25, 50, 25` produce the same timing. The sequence's `transform` asserts that `t` lies within `0.0`-`1.0`. To **hold** a value for a stage, use `ConstantTween<T>(value)`, whose `lerp` always returns the same value. `FlippedTweenSequence` is a `double`-only variant that evaluates the sequence rotated half a turn, for animations that should mirror on the way back. ## chain: composing Animatables `Animatable.chain(Animatable<double> parent)` returns a new `Animatable` whose `transform(t)` is `this.transform(parent.transform(t))`. Read that carefully, because the order is the classic slip: - in `tween.chain(CurveTween(curve: Curves.easeIn))`, the **curve runs first**, then the tween interpolates the eased fraction; - writing it the other way, `CurveTween(...).chain(tween)`, feeds the tween's output (say `0`-`100`) into a curve, and curves assert that their input lies within `0.0`-`1.0`. Because `chain` returns an `Animatable` rather than an `Animation`, no intermediate object with listeners is allocated, and the result can go anywhere an `Animatable` goes - including the `tween` of a `TweenSequenceItem`, which is how each stage gets its own easing. ## Worked example: a pulse ```dart final Animatable<double> pulse = TweenSequence<double>(<TweenSequenceItem<double>>[ TweenSequenceItem<double>( tween: Tween<double>(begin: 1.0, end: 1.2).chain(CurveTween(curve: Curves.easeOut)), weight: 40, ), TweenSequenceItem<double>(tween: ConstantTween<double>(1.2), weight: 20), TweenSequenceItem<double>( tween: Tween<double>(begin: 1.2, end: 1.0).chain(CurveTween(curve: Curves.easeIn)), weight: 40, ), ]); _scale = _controller.drive(pulse); // built once, in initState ``` With a 600 ms controller the badge grows for 240 ms, holds for 120 ms and shrinks for 240 ms, and `reverse()` plays the same three stages backwards. ## TweenSequence versus Interval | | `TweenSequence` | `Interval` windows | |---|---|---| | Output | one value built from consecutive stages | one animation per window | | Slices | end to end, never overlapping | may overlap freely | | Sized by | relative `weight` | absolute fractions `begin`, `end` | | Typical use | one property with several phases | several properties or elements staggered | ## Pitfalls - Treating weights as percentages that must add to 100 - they are only proportions. - Reversing the `chain` order and getting a curve assertion, or a curve applied to the wrong quantity. - Driving the **sequence's parent** with an overshooting curve such as `Curves.elasticOut`: the out-of-range `t` trips the sequence's assert. Put overshoot inside an item's own `chain` instead. - Rebuilding the `TweenSequence` in `build()`; its constructor computes the slices, so build it once and reuse it. - Expecting `chain` to return something you can listen to: it returns an `Animatable`, so you still bind the result with `animate()` or `drive()`. ## How to answer in an interview Name the two tools, then say what each divides. `TweenSequence` divides **one value's** timeline into consecutive stages by relative weight; `chain` composes **transformations** so a stage can carry its own curve. Mention `ConstantTween` for holds, the argument-first order of `chain`, and the 0-1 assert on the sequence's input, and you have covered what interviewers check.

  • In what order does Tween<double>(begin: 0, end: 100).chain(CurveTween(curve: Curves.easeIn)) apply its steps?
    The argument runs first. `chain` returns an Animatable whose `transform` is `tween.transform(curveTween.transform(t))`, so `t` is eased and the eased fraction is then interpolated between 0 and 100. Writing `CurveTween(...).chain(tween)` instead would feed values up to 100 into a curve, which asserts because curves accept only 0.0 to 1.0.
  • Why can a TweenSequence assert when its parent animation uses Curves.elasticOut?
    `TweenSequence.transform` asserts that `t` lies between 0.0 and 1.0, and elastic curves overshoot that range mid-animation. Feeding their output into the sequence trips the assert in debug builds. Keep the sequence's parent in range and put any overshooting curve inside an item's own `chain`, on a `Tween` that tolerates extrapolation.

saying these in an interview costs you the question

  • TweenSequenceItem weights must add up to 100.
  • a.chain(b) applies a first and then b.
  • Holding a value between stages needs a second controller or a timer.
  • TweenSequence segments can overlap like Interval windows.
  • chain returns an Animation you can add listeners to.