Flutter has a reputation for allowing developers to build beautiful animated UIs and it's rightfully so. While you can build absolutely everything with just default Flutter classes and widgets, it sometimes gets way too tedious and time consuming to do so. Let's see how to make implementing even the most complex animations a breeze.

The simple_animations is all about... simplifying animations! There are multiple areas of animations where this package provides an easier way out. We're going to focus on one of them - the timeline tween.

To find out about the other features of the package, check out its official documentation.

You should already have a solid grasp of how animations work in Flutter to follow along with this tutorial. If you need to brush up on the absolute basics, feel free to learn from this tutorial. To learn about the default Flutter's way of simplifying animations at least a bit, you can take a look at another tutorial of mine.

Default animations

We're going to start off with a project that contains an already implemented staggered animation snatched and modified a bit from the Flutter documentation. You can get up and running so that you can follow along with this tutorial by cloning the starter project linked above.

This is how the app looks like. By tapping on the screen, you toggle the animation to go forward and then backward.

So how does the default animation code look like and where can it be improved? Just from a brief glance, you can see that the following code is quite long and there are also a lot of Animation fields in the class!

main.dart

class StaggerDemo extends StatefulWidget {
  @override
  _StaggerDemoState createState() => _StaggerDemoState();
}

class _StaggerDemoState extends State<StaggerDemo>
    with TickerProviderStateMixin {
  AnimationController controller;

  Animation<double> opacity;
  Animation<double> width;
  Animation<double> height;
  Animation<EdgeInsets> padding;
  Animation<BorderRadius> borderRadius;
  Animation<Color> color;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );

    opacity = Tween<double>(
      begin: 0.0,
      end: 1.0,
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.0,
          0.100,
          curve: Curves.ease,
        ),
      ),
    );
    width = Tween<double>(
      begin: 50.0,
      end: 150.0,
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.125,
          0.250,
          curve: Curves.ease,
        ),
      ),
    );
    height = Tween<double>(begin: 50.0, end: 150.0).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.250,
          0.375,
          curve: Curves.ease,
        ),
      ),
    );
    padding = EdgeInsetsTween(
      begin: const EdgeInsets.only(bottom: 16.0),
      end: const EdgeInsets.only(bottom: 75.0),
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.250,
          0.375,
          curve: Curves.ease,
        ),
      ),
    );
    borderRadius = BorderRadiusTween(
      begin: BorderRadius.circular(4.0),
      end: BorderRadius.circular(75.0),
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.375,
          0.500,
          curve: Curves.ease,
        ),
      ),
    );
    color = ColorTween(
      begin: Colors.indigo[100],
      end: Colors.orange[400],
    ).animate(
      CurvedAnimation(
        parent: controller,
        curve: Interval(
          0.500,
          0.750,
          curve: Curves.ease,
        ),
      ),
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Staggered Animation'),
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: () {
          if (controller.status == AnimationStatus.dismissed) {
            controller.forward();
          } else if (controller.status == AnimationStatus.completed) {
            controller.reverse();
          }
        },
        child: Center(
          child: Container(
            width: 300.0,
            height: 300.0,
            decoration: BoxDecoration(
              color: Colors.black.withOpacity(0.1),
              border: Border.all(
                color: Colors.black.withOpacity(0.5),
              ),
            ),
            child: AnimatedBuilder(
              animation: controller,
              builder: _buildAnimation,
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildAnimation(BuildContext context, Widget child) {
    return Container(
      padding: padding.value,
      alignment: Alignment.bottomCenter,
      child: Opacity(
        opacity: opacity.value,
        child: Container(
          width: width.value,
          height: height.value,
          decoration: BoxDecoration(
            color: color.value,
            border: Border.all(
              color: Colors.indigo[300],
              width: 3.0,
            ),
            borderRadius: borderRadius.value,
          ),
        ),
      ),
    );
  }
}

void main() {
  runApp(MaterialApp(home: StaggerDemo()));
}

Aside from the sheer number of the Animation fields, notice how the Interval curves are defined. Staggered animations are made possible by running on a certain interval of the parent animation. The parent still runs from 0 to 1 continuously as usual, but it's precisely the Interval curve which makes the animation run only between 0.25 and 0.5, let's say.

The problem lies with the interval beginnings and ends being defined with absolute values (as opposed to relative values). Let's say you want to make the very first opacity interval longer. Well, then you'd need to manually edit all of the intervals coming after it to account for the changed end time of the opacity.

Sure, we could solve this by creating a double opacityStart and opacityEnd. The interval following immediately after that (width) would have the widthStart set to opacityEnd + 0.25...
In this way, we'd achieve relative intervals, but at what cost?! The code would be littered with even more fields.

The last issue is that we're manually instantiating and then disposing of an AnimationController. While controllers are in no way evil, it's good to hide them behind an abstractions whenever you can.

All of these problems can be solved with the simple_animations package. Let's start with the most pressing issue - absolute intervals. But first...

Adding dependencies

In addition to depending on the simple_animations package, we're also going to add a dependency to supercharged. This adds some nice extensions for creating Tweens and Durations but it's in no way necessary to use simple_animations.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  simple_animations: ^2.5.1
  supercharged: ^1.12.0

TimelineTween

In the default Flutter approach to animations, we did the following for every single property we wanted to animate:

  1. Create a Tween
  2. Animate or "drive" that Tween with a CurvedAnimation whose parent is the single AnimationController we have in the widget.
  3. Set the curve for the CurvedAnimation to be an Interval curve, set the begin and end times (or rather fractions of the 0 to 1 progression which the AnimationController is going through)
  4. Change the curve during the interval from Curves.linear to Curves.ease

With the TimelineTween, we will have a single top-level tween encompassing all of the individual Tweens for individual properties. This vastly reduces the number of fields we have to keep track of. Additionally, this tween makes it easy and clutter-free to define relative intervals.

Animated properties enum

Since we'll now have a single TimelineTween encompass all of the individual property Tweens, we need to have a way to tell which property the Tween belongs to. We can do that with an enum.

main.dart

enum AnimProps {
  opacity,
  width,
  height,
  padding,
  borderRadius,
  color,
}

With this enum created, you can now delete all of the Animation fields and also their initializations from initState. We're now going to have just one animation field, its generic parameter being a TimelineValue<AnimProps>. This is just a simple container class that's produced by the TimelineTween which contains all the individual animated properties' values.

Also, notice the supercharged import. We're going to need it in a little while.

main.dart

import 'package:simple_animations/simple_animations.dart';
import 'package:supercharged/supercharged.dart';

...

class _StaggerDemoState extends State<StaggerDemo>
    with TickerProviderStateMixin {
  AnimationController controller;

  Animation<TimelineValue<AnimProps>> animation;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );

    // TODO: Set the animation by driving a TimelineTween
  }
  ...
}

Scenes and properties

Staggered animations are handled using scenes. A scene defines a span of time in which the properties being animated within that scene are going to be updated with new values. In a way, it's like the Interval curve from default Flutter but a scene can be used to animate multiple properties, not just a single one.

Since we've previously had multiple Intervals with different start and end times, we're also going to have multiple scenes. Let's start with the one that will animate only a single property - opacity.

Note that we're using an extension property so that we can write 0.milliseconds instead of Duration(milliseconds: 0).

main.dart

@override
void initState() {
  super.initState();

  ...

  animation = TimelineTween()
      // Opacity
      .addScene(
        begin: 0.milliseconds,
        end: 100.milliseconds,
        curve: Curves.ease,
      )
      // Animate the opacity property from 0 to 1 within this scene
      .animate(AnimProps.opacity, tween: Tween(begin: 0, end: 1))
      // We'll chain more scenes here...
}

This first scene is defined in absolute terms - we've firmly defined its begin and end times. This is fine since it's the very first scene and while we could create absolute scenes even for all the other properties, we'd be in the same situation as with the default Flutter Interval curves. That's why we want to create all of the subsequent scenes using the addSubsequentScene method.

These calls are chained one onto another - the scene is relative to the previous one, after all. Also, there are no begin and end times but only duration and delay instead.

Chaining method calls like this can easily become unreadable. There are multiple ways to keep the code readable, the simplest one is to add comments before each scene telling which properties are being animated there.

main.dart

@override
void initState() {
  super.initState();

  ...

  animation = TimelineTween<AnimProps>()
      // Opacity - defined with absolute begin and end times
      .addScene(
        begin: 0.milliseconds,
        end: 100.milliseconds,
        curve: Curves.ease,
      )
      .animate(AnimProps.opacity, tween: Tween(begin: 0.0, end: 1.0))
      // Width - this scene is relative to the previous one
      // There's no begin and end, only delay and duration
      .addSubsequentScene(
        delay: 25.milliseconds,
        duration: 125.milliseconds,
        curve: Curves.ease,
      )
      .animate(AnimProps.width, tween: Tween(begin: 50.0, end: 150.0))
      // Height and Padding
      .addSubsequentScene(
        duration: 125.milliseconds,
        curve: Curves.ease,
      )
      .animate(AnimProps.height, tween: Tween(begin: 50.0, end: 150.0))
      .animate(
        AnimProps.padding,
        tween: EdgeInsetsTween(
          begin: const EdgeInsets.only(bottom: 16.0),
          end: const EdgeInsets.only(bottom: 75.0),
        ),
      )
      // BorderRadius
      .addSubsequentScene(
        duration: 125.milliseconds,
        curve: Curves.ease,
      )
      .animate(
        AnimProps.borderRadius,
        tween: BorderRadiusTween(
          begin: BorderRadius.circular(4.0),
          end: BorderRadius.circular(75.0),
        ),
      )
      // Color
      .addSubsequentScene(
        duration: 250.milliseconds,
        curve: Curves.ease,
      )
      .animate(
        AnimProps.color,
        tween: ColorTween(
          begin: Colors.indigo[100],
          end: Colors.orange[400],
        ),
      )
      // Get the Tween so that we can drive it with the AnimationController
      .parent
      .animatedBy(controller);
}

Aside from adding scenes and animating properties, we also need to drive the TimelineTween with the AnimationController. That's done on the last two lines above.

Rebuilding the UI

Of course, condensing everything into just one animation field broke the code responsible for rebuilding the widget tree in the AnimatedBuilder. We now need to extract the values for the individual properties, such as opacity or width. We're again going to use our AnimProps enum for that.

main.dart

Widget _buildAnimation(BuildContext context, Widget child) {
  return Container(
    padding: animation.value.get(AnimProps.padding),
    alignment: Alignment.bottomCenter,
    child: Opacity(
      opacity: animation.value.get(AnimProps.opacity),
      child: Container(
        width: animation.value.get(AnimProps.width),
        height: animation.value.get(AnimProps.height),
        decoration: BoxDecoration(
          color: animation.value.get(AnimProps.color),
          border: Border.all(
            color: Colors.indigo[300],
            width: 3.0,
          ),
          borderRadius: animation.value.get(AnimProps.borderRadius),
        ),
      ),
    ),
  );
}

After this last change, we have now successfully refactored the staggered animation using the simple_animations package. The TimelineTween makes the code more maintainable and also easier to write.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time 😅 Yet he still manages to squeeze in tough workouts 💪

You may also like

Snackbar, Toast & Dialog in Flutter (Flash Package)

Search Bar in Flutter – Logic & Material UI

Flutter Integration Test Tutorial + Firebase Test Lab & Codemagic

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
>