Animations in Flutter

1. Introduction

Animations are a great way to improve the user experience of your app, communicate important information to the user, and make your app more polished and enjoyable to use.

Overview of Flutter's animation framework

Flutter displays animation effects by re-building a portion of the widget tree on each frame. It provides pre-built animation effects and other APIs to make creating and composing animations easier.

  • Implicit animations are pre-built animation effects that run the entire animation automatically. When the target value of the animation changes, it runs the animation from the current value to the target value, and displays each value in between so that the widget animates smoothly. Examples of implicit animations include AnimatedSize, AnimatedScale, and AnimatedPositioned.
  • Explicit animations are also pre-built animation effects, but require an Animation object in order to work. Examples include SizeTransition, ScaleTransition or PositionedTransition.
  • Animation is a class that represents a running or stopped animation, and is composed of a value representing the target value the animation is running to, and the status, which represents the current value the animation is displaying on screen at any given time. It is a subclass of Listenable, and notifies its listeners when the status changes while the animation is running.
  • AnimationController is a way to create an Animation and control its state. Its methods such as forward(), reset(), stop(), and repeat() can be used to control the animation without the need to define the animation effect that is being displayed, such as the scale, size, or position.
  • Tweens are used to interpolate values between a beginning and end value, and can represent any type, such as a double, Offset, or Color.
  • Curves are used to adjust the rate of change of a parameter over time. When an animation runs, it's common to apply an easing curve to make the rate of change faster or slower at the beginning or end of the animation. Curves take an input value between 0.0 and 1.0 and return an output value between 0.0 and 1.0.

What you'll build

In this codelab, you're going to build a multiple-choice quiz game that features various animation effects and techniques.

3026390ad413769c.gif

You'll see how to...

  • Build a widget that animates its size and color
  • Build a 3D card flip effect
  • Use fancy pre-built animation effects from the animations package
  • Add predictive back gesture support available on the latest version of Android

What you'll learn

In this codelab you'll learn:

  • How to use implicitly animated effects to achieve great looking animations without requiring a lot of code.
  • How to use explicitly animated effects to configure your own effects using pre-built animated widgets such as AnimatedSwitcher or an AnimationController.
  • How to use AnimationController to define your own widget that displays a 3D effect.
  • How to use the animations package to display fancy animation effects with minimal setup.

What you'll need

  • The Flutter SDK
  • An IDE, such as VSCode or Android Studio / IntelliJ

2. Set up your Flutter development environment

You need two pieces of software to complete this lab — the Flutter SDK and an editor.

You can run the codelab using any of these devices:

  • A physical Android (recommended for implementing predictive back in step 7) or iOS device connected to your computer and set to Developer mode.
  • The iOS simulator (requires installing Xcode tools).
  • The Android Emulator (requires setup in Android Studio).
  • A browser (Chrome is required for debugging).
  • A Windows, Linux, or macOS desktop computer. You must develop on the platform where you plan to deploy. So, if you want to develop a Windows desktop app, you must develop on Windows to access the appropriate build chain. There are operating system-specific requirements that are covered in detail on docs.flutter.dev/desktop.

Verify your installation

To verify that your Flutter SDK is configured correctly, and you have at least one of the above target platforms installed, use the Flutter Doctor tool:

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. Run the starter app

Download the starter app

Use git to clone the start app from the flutter/samples repository on GitHub.

$ git clone https://github.com/flutter/codelabs.git
$ cd codelabs/animations/step_01/

Alternatively, you can download the source code as a .zip file.

Run the app

To run the app, use the flutter run command and specify a target device, such as android, ios, or chrome. For the full list of supported platforms, see the Supported platforms page.

$ flutter run -d android

You can also run and debug the app using your IDE of choice. See the official Flutter documentation for more information.

Tour the code

The starter app is a multiple-choice quiz game that consists of two screens following the model-view-view-model, or MVVM design pattern. The QuestionScreen (View) uses the QuizViewModel (View-Model) class to ask the user multiple choice questions from the QuestionBank (Model) class.

  • home_screen.dart - Displays a screen with a New Game button
  • main.dart - Configures the MaterialApp to use Material 3 and show the home screen
  • model.dart - Defines the core classes used throughout the app
  • question_screen.dart - Displays the UI for the quiz game
  • view_model.dart - Stores the state and logic for the quiz game, displayed by the QuestionScreen

fbb1e1f7b6c91e21.png

The app doesn't support any animated effects yet, except for the default view transition displayed by Flutter's Navigator class when the user presses the New Game button.

4. Use implicit animation effects

Implicit animations are a great choice in many situations, since they don't require any special configuration. In this section, you will update the StatusBar widget so that it displays an animated scoreboard. To find common implicit animation effects, browse the ImplicitlyAnimatedWidget API documentation.

206dd8d9c1fae95.gif

Create the unanimated scoreboard widget

Create a new file, lib/scoreboard.dart with the following code:

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color:
                  score < i + 1 ? Colors.grey.shade400 : Colors.yellow.shade700,
            )
        ],
      ),
    );
  }
}

Then add the Scoreboard widget in the StatusBar widget's children, replacing the Text widgets that previously showed the score and total question count. Your editor should automatically add the required import "scoreboard.dart" at the top of the file.

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

This widget displays a star icon for each question. When a question is answered correctly, another star lights up instantly without any animation. In the following steps, you'll help inform the user that their score changed by animating its size and color.

Use an implicit animation effect

Create a new widget called AnimatedStar that uses an AnimatedScale widget to change the scale amount from 0.5 to 1.0 when the star becomes active:

lib/scoreboard.dart

​​import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(                                      // NEW
              isActive: score > i,                             // NEW
            )                                                  // NEW
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

Now, when the user answers a question correctly, the AnimatedStar widget updates its size using an implicit animation. The Icon's color is not animated here, only the scale, which is done by the AnimatedScale widget.

84aec4776e70b870.gif

Use a Tween to interpolate between two values

Notice that the color of the AnimatedStar widget changes immediately after the isActive field changes to true.

To achieve an animated color effect, you might try to use an AnimatedContainer widget (which is another subclass of ImplicitlyAnimatedWidget), because it can automatically animate all of its attributes, including the color. Unfortunately, our widget needs to display an icon, not a container.

You might also try AnimatedIcon, which implements transition effects between the shapes of the icons. But there isn't a default implementation of a star icon in the AnimatedIcons class.

Instead, we'll use another subclass of ImplicitlyAnimatedWidget called TweenAnimationBuilder, which takes a Tween as a parameter. A tween is a class that takes two values (begin and end) and calculates the in-between values, so that an animation can display them. In this example, we'll use a ColorTween, which satisfies the Tween<Color> interface required to build our animation effect.

Select the Icon widget and use the "Wrap with Builder" quick action in your IDE, change the name to TweenAnimationBuilder. Then provide the duration and ColorTween.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(
            Icons.star,
            size: 50,
            color: value,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

Now, hot-reload the app to see the new animation.

8b0911f4af299a60.gif

Notice that the end value of our ColorTween changes based on the value of the isActive parameter. This is because TweenAnimationBuilder re-runs its animation whenever the Tween.end value changes. When this happens, the new animation runs from the current animation value to the new end value, which allows you to change the color at any time (even while the animation is running) and display a smooth animation effect with the correct in-between values.

Apply a Curve

Both of these animation effects run at a constant rate, but animations are often more visually interesting and informative when they speed up or slow down.

A Curve applies an easing function, which defines the rate of change of a parameter over time. Flutter ships with a collection of pre-built easing curves in the Curves class, such as easeIn or easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

These diagrams (available on the Curves API documentation page) give a clue to how curves work. Curves convert an input value between 0.0 and 1.0 (displayed on the x axis) to an output value between 0.0 and 1.0 (displayed on the y axis). These diagrams also show a preview of what various animation effects look like when they use an easing curve.

Create a new field in AnimatedStar called _curve and pass it as a parameter to the AnimatedScale and TweenAnimationBuilder widgets.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(
            Icons.star,
            size: 50,
            color: value,
          );
        },
      ),
    );
  }
}

In this example, the elasticOut curve provides an exaggerated spring effect that starts with a spring motion and balances out towards the end.

8f84142bff312373.gif

Hot reload the app to see this curve applied to AnimatedSize and TweenAnimationBuilder.

206dd8d9c1fae95.gif

Use DevTools to enable slow animations

To debug any animation effect, Flutter DevTools provides a way to slow down all the animations in your app, so you can see the animation more clearly.

To open DevTools, make sure the app is running in debug mode, and open the Widget Inspector by selecting it in the Debug toolbar in VSCode or by selecting the Open Flutter DevTools button in the Debug tool window in IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Once the widget inspector is open, click the Slow animations button in the toolbar.

adea0a16d01127ad.png

5. Use explicit animation effects

Like implicit animations, explicit animations are pre-built animation effects, but instead of taking a target value, they take an Animation object as a parameter. This makes them useful in situations where the animation is already defined by a navigation transition, AnimatedSwitcher, or AnimationController, for example.

Use an explicit animation effect

To get started with an explicit animation effect, wrap the Card widget with an AnimatedSwitcher.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

AnimatedSwitcher uses a cross-fade effect by default, but you can override this using the transitionBuilder parameter. The transition builder provides the child widget that was passed to the AnimatedSwitcher, and an Animation object. This is a great opportunity to use an explicit animation.

For this codelab, the first explicit animation we'll use is SlideTransition, which takes an Animation<Offset> that defines the start and end offset that the incoming and outgoing widgets will move between.

Tweens have a helper function, animate(), that converts any Animation into another Animation with the tween applied. This means that a Tween<Offset> can be used to convert the Animation<double> provided by the AnimatedSwitcher into an Animation<Offset>, to be provided to the SlideTransition widget.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation =
            CurveTween(curve: Curves.easeInCubic).animate(animation);
        final offsetAnimation =
            Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
                .animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Note that this uses Tween.animate to apply a Curve to the Animation, and then to convert it from a Tween<double> that ranges from 0.0 to 1.0, to a Tween<Offset> that transitions from -0.1 to 0.0 on the x-axis.

Alternatively, the Animation class has a drive() function that takes any Tween (or Animatable) and converts it into a new Animation. This allows tweens to be "chained", making the resulting code more concise:

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

Another advantage to using explicit animations is that they can be easily composed together. Add another explicit animation, FadeTransition that uses the same curved animation by wrapping the SlideTransition widget.

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation =
        CurveTween(curve: Curves.easeInCubic).animate(animation);
    final offsetAnimation =
        Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
            .animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

Customize the layoutBuilder

You might notice a small problem with the AnimationSwitcher. When a QuestionCard switches to a new question, it lays it out in the center of the available space while the animation is running, but when the animation is stopped, the widget snaps to the top of the screen. This causes a janky animation because the final position of the question card doesn't match the position while the animation is running.

d77de181bdde58f7.gif

To fix this, the AnimatedSwitcher also has a layoutBuilder parameter, which can be used to define the layout. Use this function to configure the layout builder to align the card to the top of the screen:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

This code is a modified version of the defaultLayoutBuilder from the AnimatedSwitcher class, but uses Alignment.topCenter instead of Alignment.center.

Summary

  • Explicit animations are animation effects that take an Animation object (in contrast to ImplicitlyAnimatedWidgets, which take a target value and duration)
  • The Animation class represents a running animation, but does not define a specific effect.
  • Use Tween().animate or Animation.drive() to apply Tweens and Curves (using CurveTween) to an animation.
  • Use AnimatedSwitcher;s layoutBuilder parameter to adjust how it lays out its children.

6. Control the state of an animation

So far, every animation has been run automatically by the framework. Implicit animations run automatically, and explicit animation effects require an Animation to work correctly. In this section, you'll learn how to create your own Animation objects using an AnimationController, and use a TweenSequence to combine Tweens together.

Run an animation using an AnimationController

To create an animation using an AnimationController, you'll need to follow these steps:

  1. Create a StatefulWidget
  2. Use the SingleTickerProviderStateMixin mixin in your State class to provide a Ticker to your AnimationController
  3. Initialize the AnimationController in the initState lifecycle method, providing the current State object to the vsync (TickerProvider) parameter.
  4. Make sure your widget re-builds whenever the AnimationController notifies its listeners, either by using AnimatedBuilder or by calling listen() and setState manually.

Create a new file, flip_effect.dart and copy-paste the following code:

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

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

    _animationController =
        AnimationController(vsync: this, duration: widget.duration);

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

This class sets up an AnimationController and re-runs the animation whenever the framework calls didUpdateWidget to notify it that the widget configuration has changed, and there might be a new child widget.

The AnimatedBuilder ensures that the widget tree is re-built whenever the AnimationController notifies its listeners, and the Transform widget is used to apply a 3D rotation effect to simulate a card being flipped over.

To use this widget, wrap each answer card in with a CardFlipEffect widget. Make sure to provide a key to the Card widget:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

Now hot-reload the app to see the answer cards flip over using the CardFlipEffect widget.

5455def725b866f6.gif

You might notice that this class looks a lot like an explicit animation effect. In fact, it's often a good idea to extend the AnimatedWidget class directly to implement your own version. Unfortunately, since this class needs to store the previous widget in its State, it needs to use a StatefulWidget. To learn more about creating your own explicit animation effects, see the API documentation for AnimatedWidget.

Add a delay using TweenSequence

In this section, you'll add a delay to the CardFlipEffect widget so that each card flips over one at a time. To get started, add a new field called delayAmount.

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

Then add the delayAmount to the AnswerCards build method.

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

Then in _CardFlipEffectState, create a new Animation that applies the delay using a TweenSequence. Notice that this does not use any utilities from the dart:async library, like Future.delayed. This is because the delay is part of the animation and not something the widget explicitly controls when it uses the AnimationController. This makes the animation effect easier to debug when enabling slow animations in DevTools, since it uses the same TickerProvider.

To use a TweenSequence, create two TweenSequenceItems, one containing a ConstantTween that keeps the animation at 0 for a relative duration and a regular Tween that goes from 0.0 to 1.0.

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay; // NEW

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

    _animationController = AnimationController(
        vsync: this, duration: widget.duration * (widget.delayAmount + 1));

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([   // NEW
      if (widget.delayAmount > 0)                   // NEW
        TweenSequenceItem(                          // NEW
          tween: ConstantTween<double>(0.0),        // NEW
          weight: widget.delayAmount,               // NEW
        ),                                          // NEW
      TweenSequenceItem(                            // NEW
        tween: Tween(begin: 0.0, end: 1.0),         // NEW
        weight: 1.0,                                // NEW
      ),                                            // NEW
    ]).animate(_animationController);               // NEW
  }

Finally, replace the AnimationController's animation with the new delayed animation in the build method.

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                ? _previousChild
                : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

Now hot reload the app and watch the cards flip one by one. For a challenge try experimenting with changing the perspective of the 3D effect provided by the Transform widget..

28b5291de9b3f55f.gif

7. Use custom navigation transitions

So far, we've seen how to customize effects on a single screen, but another way to use animations is to use them to transition between screens. In this section, you'll learn how to apply animation effects to screen transitions using built-in animation effects and fancy pre-built animation effects provided by the official animations package on pub.dev

Animate a navigation transition

The PageRouteBuilder class is a Route that allows you to customize the transition animation. It allows you to override its transitionBuilder callback, which provides two Animation objects, representing the incoming and outgoing animation that is run by the Navigator.

To customize the transition animation, replace the MaterialPageRoute with a PageRouteBuilder and to customize the transition animation when the user navigates from the HomeScreen to the QuestionScreen. Use a FadeTransition (an explicitly animated widget) to make the new screen fade in on top of the previous screen.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // NEW
        pageBuilder: (context, animation, secondaryAnimation) { // NEW
          return QuestionScreen();                              // NEW
        },                                                      // NEW
        transitionsBuilder:                                     // NEW
            (context, animation, secondaryAnimation, child) {   // NEW
          return FadeTransition(                                // NEW
            opacity: animation,                                 // NEW
            child: child,                                       // NEW
          );                                                    // NEW
        },                                                      // NEW
      ),                                                        // NEW
    );
  },
  child: Text('New Game'),
),

The animations package provides fancy pre-built animation effects, like FadeThroughTransition. Import the animations package and replace the FadeTransition with the FadeThroughTransition widget:

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
          return FadeThroughTransition(                          // NEW
            animation: animation,                                // NEW
            secondaryAnimation: secondaryAnimation,              // NEW
            child: child,                                        // NEW
          );                                                     // NEW
        },
      ),
    );
  },
  child: Text('New Game'),
),

Customize the predictive back animation

1c0558ffa3b76439.gif

Predictive back is a new Android feature that allows the user to peek behind the current route or app to see what's behind it before navigating. The peek animation is driven by the location of the user's finger as they drag back across the screen.

Flutter supports system predictive back by enabling the feature at the system level when Flutter has no routes to pop on its navigation stack, or in other words, when a back would exit the app. This animation is handled by the system and not by Flutter itself.

Flutter also supports predictive back when navigating between routes within a Flutter app. A special PageTransitionsBuilder called PredictiveBackPageTransitionsBuilder listens for system predictive back gestures and drives its page transition with the gesture's progress.

Predictive back is only supported in Android U and above, but Flutter will gracefully fall back to the original back gesture behavior and ZoomPageTransitionBuilder. See our blog post for more, including a section about how to set it up in your own app.

In the ThemeData configuration for your app, configure the PageTransitionsTheme to use PredictiveBack on Android, and the fade-through transition effect from the animations package on other platforms:

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

Now you can change the Navigator.push() call back to a MaterialPageRoute.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) {       // NEW
        return const QuestionScreen();             // NEW
      }),                                          // NEW
    );
  },
  child: Text('New Game'),
),

Use FadeThroughTransition to change the current question

The AnimatedSwitcher widget only provides one Animation in its builder callback. To address this, the animations package provides a PageTransitionSwitcher.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                                          // NEW
      layoutBuilder: (entries) {                                            // NEW
        return Stack(                                                       // NEW
          alignment: Alignment.topCenter,                                   // NEW
          children: entries,                                                // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      transitionBuilder: (child, animation, secondaryAnimation) {           // NEW
        return FadeThroughTransition(                                       // NEW
          animation: animation,                                             // NEW
          secondaryAnimation: secondaryAnimation,                           // NEW
          child: child,                                                     // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Use OpenContainer

77358e5776eb104c.png

The OpenContainer widget from the animations package provides a container transform animation effect that expands to create a visual connection between two widgets.

The widget returned by closedBuilder is displayed initially, and expands to the widget returned by openBuilder when the container is tapped or when the openContainer callback is called.

To connect the openContainer callback to the view-model, add a new pass the viewModel into the QuestionCard widget and store a callback that will be used to show the "Game Over" screen:

lib/question_screen.dart

class QuestionScreen extends StatefulWidget {
  const QuestionScreen({super.key});

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel =
      QuizViewModel(onGameOver: _handleGameOver);
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                        ? () {
                            viewModel.getNextQuestion();
                          }
                        : null,
                child: const Text('Next'),
              )
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

Add a new widget, GameOverScreen:

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text(
              'You Win!',
              style: Theme.of(context).textTheme.displayLarge,
            ),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

In the QuestionCard widget, replace the Card with an OpenContainer widget from the animations package, adding two new fields for the viewModel and open container callback:

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. Congratulations

Congratulations, you've successfully added animation effects to a Flutter app, and learned about the core components of Flutter's animation system. Specifically, you learned:

  • How to use an ImplicitlyAnimatedWidget
  • How to use an ExplicitlyAnimatedWidget
  • How to apply Curves and Tweens to an animation
  • How to use pre-built transition widgets such as AnimatedSwitcher or PageRouteBuilder
  • How to use fancy pre-built animation effects from the animations package, such as FadeThroughTransition and OpenContainer
  • How to customize the default transition animation, including adding support for Predictive Back on Android.

3026390ad413769c.gif

What's next?

Check out some of these codelabs:

Or download the animations sample app, which showcases various animation techniques

Further reading

You can find more animations resources on flutter.dev:

Or check out these articles on Medium:

Reference docs