DEV Community

Cover image for animate_chain: chained Flutter animations without the lifecycle leaks
Abdeldjalil Chougui
Abdeldjalil Chougui

Posted on Originally published at abdeldjalilchougui.biz

animate_chain: chained Flutter animations without the lifecycle leaks

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
Enter fullscreen mode Exit fullscreen mode
import 'package:animate_chain/animate_chain.dart';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 delay starts at the same time as the previous effect.
  • An effect with delay: d starts at the current baseline plus d. The baseline is 0 until the first then().
  • After then(delay: d), the end of the previous effect plus d becomes 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());
Enter fullscreen mode Exit fullscreen mode

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:

  • AnimateManager is called EffectChain.
  • EffectList, the empty effect(), Animate.reparentTypes, Adapter.direction and ValueAdapter are not supported. Put Expanded or Positioned outside the animated widget.
  • The align, boxShadow, elevation, crossfade, followPath and shader effects are not included. Use custom() for those.
  • shimmer has no padding parameter and adds no padding, so the child's size is unchanged.
  • shake rounds the cycle count so the child always comes to rest in place.
  • .microseconds, .hours and .days are not there; use Duration(...).

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 AnimationController between several Animates with different total durations is not supported, because each one sets the controller's duration.
  • callback and manual scrubbing. callback works 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.disableAnimations is true, delays are skipped and the end state is shown immediately. onComplete still fires; onPlay does not, so loops started in onPlay stay off. Adapters keep working, because the user drives them. You can turn this off with animate(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)