Chained animation syntax made Flutter animations pleasant to write. Instead of a StatefulWidget, an AnimationController, a Tween and an AnimatedBuilder, you write widget.animate().fadeIn().slideY() and move on. flutter_animate made this style popular.
The style has a cost when the lifecycle is not handled carefully. The animate_chain README lists the issues it found in flutter_animate 4.5.2, each with a link to the upstream issue: a delay that still fires after the widget is removed and calls forward() on a disposed controller, onComplete firing several times, a CurvedAnimation created on every build and never disposed, and a shimmer that rebuilds a ShaderMask every frame.
animate_chain covers the most-used core of the flutter_animate API in a clean-room rewrite, and fixes those lifecycle problems.
What it does
Chained or declarative. Write child.animate().fadeIn().scale(), or Animate(effects: [FadeEffect(), ScaleEffect()], child: child) when you want a reusable list of effects.
Sequencing with then(). It works the same way as in flutter_animate: each effect after then() starts when the previous effect ends.
Inherited timing. An effect that omits delay, duration or curve inherits it from the previous effect. The first effect falls back to Animate.defaultDuration (300 ms) and Animate.defaultCurve (linear).
18 effects. fade, slide, move, scale, rotate, flip, blur, tint, color, shimmer, shake, saturate, custom, visibility, callback, listen, swap and toggle, plus their shortcuts (fadeIn, slideX, scaleXY, shakeY, blurXY, flipH, show, hide and others).
Staggered lists. [a, b, c].animate(interval: 80.ms).fadeIn().
State-driven animation. animate(target: selected ? 1 : 0) animates from the current position whenever target changes.
External drivers. Pass your own controller, or use ScrollAdapter, ValueNotifierAdapter or ChangeNotifierAdapter.
Accessibility. When MediaQuery.disableAnimations is on, the widget jumps to its end state. This is configurable.
Leak-free. One AnimationController per Animate, and no CurvedAnimations to dispose. Pending delays are cancelled on dispose, and every listener is removed.
How to use it
Add it from GitHub:
dependencies:
animate_chain:
git: https://github.com/AbdeldjalilChougui/animate_chain
import 'package:animate_chain/animate_chain.dart';
A few examples from the README:
// Chained. Effects run in parallel unless delayed or sequenced with then().
Text('Hello')
.animate()
.fadeIn(duration: 400.ms)
.slideY(begin: 0.3, curve: Curves.easeOut) // inherits 400ms
.then(delay: 200.ms) // starts at 400 + 200 ms
.shakeX();
// Staggered list.
Column(
children: [for (final item in items) ListTile(title: Text(item))]
.animate(interval: 60.ms)
.fadeIn()
.moveX(begin: -24),
);
// State-driven: animates to the new target when `selected` changes.
card.animate(target: selected ? 1 : 0).scaleXY(begin: 1, end: 1.05).tint(end: 0.2);
// Looping shimmer placeholder.
placeholder.animate(onPlay: (c) => c.repeat()).shimmer(duration: 1200.ms);
// Driven by scrolling: progress 0 at offset 0, 1 at offset 300.
header.animate(adapter: ScrollAdapter(scrollController, end: 300)).fadeOut(begin: 1);
The example/ folder has a live demo of every effect (cd example && flutter run).
The timing rules
There are three cases, and they are worth learning once:
- An effect with no
delaystarts at the same time as the previous effect. - An effect with
delay: dstarts at the current baseline plusd. The baseline is 0 until the firstthen(). - After
then(delay: d), the end of the previous effect plusdbecomes the new baseline.
duration and curve are always inherited from the previous effect when omitted. The total length of an Animate is the latest end time of any effect.
When an animation restarts
A restart happens only when the key changes, when the chain's timing changes (effect types, delays or durations), or when the controller changes. Rebuilding with new values, such as begin, end, colours or builders, updates the output in place without restarting. To replay an animation on purpose, change its key: widget.animate(key: ValueKey(runId)).fadeIn().
onComplete fires exactly once per completion, and onPlay always runs before it, even for zero-length animations. A callback that would otherwise run during a build is deferred to the end of the frame, so calling setState in it is safe.
Writing your own effect
An effect is a class with a build method. buildAnimation gives a listener-free view of the controller, so there is nothing to dispose:
class SkewEffect extends Effect<double> {
const SkewEffect({super.delay, super.duration, super.curve, super.begin = 0.3, super.end = 0});
@override
Widget build(BuildContext context, Widget child, AnimationController controller, EffectEntry entry) {
final skew = buildAnimation(controller, entry);
return AnimatedBuilder(
animation: skew,
child: child,
builder: (_, child) => Transform(transform: Matrix4.skewX(skew.value), child: child),
);
}
}
box.animate().addEffect(const SkewEffect());
Coming from flutter_animate
The class and method names follow flutter_animate, so most code needs only a new import. The README has a full table of what is supported. Some differences to expect:
-
AnimateManageris calledEffectChain. -
EffectList, the emptyeffect(),Animate.reparentTypes,Adapter.directionandValueAdapterare not supported. PutExpandedorPositionedoutside the animated widget. - The
align,boxShadow,elevation,crossfade,followPathandshadereffects are not included. Usecustom()for those. -
shimmerhas nopaddingparameter and adds no padding, so the child's size is unchanged. -
shakerounds the cycle count so the child always comes to rest in place. -
.microseconds,.hoursand.daysare not there; useDuration(...).
Some behaviour changed on purpose. A delay is a cancellable Timer, cancelled on dispose. With an external controller there is one status listener, removed on controller change and on dispose. delay: 0 starts in the same frame. With target, the delay applies to every change, and a newer change cancels one that is still waiting. With autoPlay: false, the first target is applied without animating. The shimmer uses one ShaderMaskLayer that is repainted without any rebuild, with the child cached behind a RepaintBoundary.
Things to know
- It is a core subset. The effects and helpers listed as not supported are left out on purpose.
-
Clean-room. The package was written from
flutter_animate's public API and documented behaviour, not from its source. - A delay is a timer. It does not keep the ticker running, but a looping animation with long internal effect delays still ticks for its whole duration.
-
One controller, one total duration. Sharing one external
AnimationControllerbetween severalAnimates with different total durations is not supported, because each one sets the controller'sduration. -
callbackand manual scrubbing.callbackworks reliably for time-driven playback and for adapters. If you set the controller's value by hand while it is animating, crossings in the same tick may be missed. -
Reduced motion. When
MediaQuery.disableAnimationsis true, delays are skipped and the end state is shown immediately.onCompletestill fires;onPlaydoes not, so loops started inonPlaystay off. Adapters keep working, because the user drives them. You can turn this off withanimate(respectReducedMotion: false). - What was tested. Only Flutter 3.35 with Dart 3.9. The declared lower bound, Flutter 3.24, was not tested.
Why I built it
The problem the README describes is not the syntax. The chained style is good, and the names here follow flutter_animate so that existing code keeps working. The problem is what happens around the edges of a widget's life: a widget removed during a delay, a rebuild that changes timing, an external controller, a zero-length animation, a user who turned animations off in the system settings.
Those cases show up as pending timers in tests, callbacks that fire twice, or objects that are never disposed. I wanted the same pleasant API with strict rules underneath: one controller per Animate, timers that are cancelled, listeners that are removed, and a documented answer for when an animation restarts and when it does not. Keeping the scope to a core subset made it possible to hold those rules everywhere.
Get the code
The source, the migration table, the example app and the tests are on GitHub: https://github.com/AbdeldjalilChougui/animate_chain. It's MIT licensed. Issues and pull requests are welcome.
Originally published at abdeldjalilchougui.biz.
Top comments (0)