Flutter Animations: Interactive Page Flip Widget

Source code on GitHub

Flutter offers some very powerful yet simple animation APIs that we can use to delight our users.

In this tutorial we will explore these APIs in detail by building an interactive page flip widget using AnimationController, AnimationBuilder, gesture detectors and custom 3D matrix transforms. The result will be a custom PageFlipBuilder widget that hides away all the complexity behind an easy-to-use API.

And at the end I will also share a new Flutter package that you can use to flip pages, cards, and widgets of any size. 🚀

Note: This tutorial assumes you're already familiar with the basics of animations with Flutter. If you're new to this topic, check the Introduction to Animations page on the Flutter website.

Flutter page flip transition
Flutter page flip transition

Live Flutter Web Demo

Before we get started, have a play with this live demo:

You can drag left/right to flip the page. Cool huh? 😎

Alright, so let's see how to build this!

Starter Project

We will focus on how to implement the page flip transition, not the pages themselves.

Note: All the code we're about to see uses null-safety, which is now available in the latest Flutter stable release.

To follow along, grab the starter project that contains two widget classes called LightHomePage and DarkHomePage.

And we will start with a simple app that uses LightHomePage() inside a black Container as the home of our MaterialApp:

class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Container( // add a black background that will prevent flickering on Android when the page flips color: Colors.black, LightHomePage(), ), ); } } class LightHomePage extends StatelessWidget { const LightHomePage({Key? key, this.onFlip}) : super(key: key); final VoidCallback? onFlip; ... } class DarkHomePage extends StatelessWidget { const DarkHomePage({Key? key, this.onFlip}) : super(key: key); final VoidCallback? onFlip; ... }

As we can see, both pages contain an onFlip callback that we will use to trigger a page flip programmatically.

If run this app we should get the following:

Front page
Front page

Our goal is to create a PageFlipBuilder widget that can flip between the two pages both programmatically (via callbacks) and interactively (via user gestures).

A good place to start is to decide what API this widget should have.

PageFlipBuilder API design

To make our PageFlipBuilder reusable, the front and back widgets should be given as arguments.

So we may be tempted to create an API that works like this:

PageFlipBuilder( front: LightHomePage(), back: DarkHomePage(), )

But since only one page will be visible on screen at any given time, it is more performant to use two WidgetBuilder arguments and let PageFlipBuilder call the correct one:

PageFlipBuilder( frontBuilder: (_) => LightHomePage(), backBuilder: (_) => DarkHomePage(), )

But what about the onFlip() callback of our pages?

LightHomePage( onFlip: /* what goes here? */ )

We should use this callback to call some kind of flip() method inside PageFlipBuilder.

As we will see PageFlipBuilder will be a StatefulWidget, so we could use a global key and hook things up like this inside MyApp:

class MyApp extends StatelessWidget { // 1. declare a GlobalKey that we will use to reference the PageFlipBuilder state final pageFlipKey = GlobalKey<PageFlipBuilderState>(); @override Widget build(BuildContext context) { return MaterialApp( home: Container( // add a black background that will prevent flickering on Android when the page flips color: Colors.black, child: PageFlipBuilder( // 2. pass the key key: pageFlipKey, frontBuilder: (_) => LightHomePage( // 3a. call an internal `flip()` method on the state class onFlip: () => pageFlipKey.currentState?.flip(), ), backBuilder: (_) => DarkHomePage( // 3b. call an internal `flip()` method on the state class onFlip: () => pageFlipKey.currentState?.flip(), ), ), ), ); } }

Of course, none of this code compiles at this stage because we haven't even created our PageFlipBuilder yet.

So let's take care of that.

Top tip: think upfront about what API you want your widgets to have. This will save you time later on and leads to widgets that are easier to use.

PageFlipBuilder widget

Let's add a page_flip_builder.dart file with the following contents:

import 'package:flutter/material.dart'; class PageFlipBuilder extends StatefulWidget { const PageFlipBuilder({ Key? key, required this.frontBuilder, required this.backBuilder, }) : super(key: key); final WidgetBuilder frontBuilder; final WidgetBuilder backBuilder; @override PageFlipBuilderState createState() => PageFlipBuilderState(); } // Note: there's no underscore here as we want this State subclass to be public. // This is so that we can call the flip() method from the outside. class PageFlipBuilderState extends State<PageFlipBuilder> { void flip() { // TODO: Implement } @override Widget build(BuildContext context) { // TODO: Replace with page flip code return widget.frontBuilder(context); } }

If we import page_flip_builder.dart in our main.dart file, we see that the code now compiles because PageFlipBuilder defines all the arguments that we're using.

And if we run the app we see that we still show the LightHomePage because the build() method returns frontBuilder(context):

Front page
Front page

Time to work out how to animate things.

Animating things with AnimationController and AnimatedBuilder

To get the animation working we need two ingredients:

  • an AnimationController to control the flip transition
  • an AnimatedBuilder widget to rotate the front/back page with a custom 3D transform, based on the animation value

As we will see, we can get the effect we want by passing the AnimationController as an input to the AnimatedBuilder.

Interaction between AnimationController and AnimatedBuilder
Interaction between AnimationController and AnimatedBuilder

So our next two goals are:

  1. Setup an AnimationController to control the flip transition
  2. Write some custom code using AnimatedBuilder to get the rotation effect

So let's see how to do this, starting with the AnimationController code.

Setting up the AnimationController

For now, we're going to flip the page programmatically. In the next tutorial, we'll make things interactive using a GestureDetector.

At this stage we need to do two things:

  • add a _showFrontSide state variable that will tell us which page we should show
  • setup our AnimationController

This is the code we need:

class PageFlipBuilderState extends State<PageFlipBuilder> // 1. Add SingleTickerProviderStateMixin with SingleTickerProviderStateMixin { // 2. Add state telling us which page we should show bool _showFrontSide = true; // 3. Our AnimationController late final AnimationController _controller; @override void initState() { // 4. Create the AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); // 5. Add a status listener _controller.addStatusListener(_updateStatus); super.initState(); } @override void dispose() { // 6. Clean things up when the widget is removed _controller.removeStatusListener(_updateStatus); _controller.dispose(); super.dispose(); } void _updateStatus(AnimationStatus status) { // 7. Toggle the state then a forward or reverse animation is complete if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { setState(() => _showFrontSide = !_showFrontSide); } } void flip() { // 8. Forward or reverse the controller depending on the state if (_showFrontSide) { _controller.forward(); } else { _controller.reverse(); } } @override Widget build(BuildContext context) { // TODO: Replace with page flip code return frontBuilder(context); } }

At this stage our app still looks the same. But we can add a listener that prints the value of our AnimationController inside initState():

// TODO: Temporary code, remove me _controller.addListener(() { print('value: ${_controller.value}'); });

If we hot restart and press the flip button on the LightHomePage, we get the following console output:

flutter: value: 0.0 flutter: value: 0.066666 flutter: value: 0.099998 flutter: value: 0.133334 ... many more lines ... flutter: value: 0.933332 flutter: value: 0.966666 flutter: value: 0.999998 flutter: value: 1.0

Since we toggle _showFrontSide inside _updateStatus() at the end of the animation, we can press the flip button again and see the values printed in reverse order (from 1.0 to 0.0).

This confirms that the AnimationController is doing the right thing and we can use it to drive our page flip animation.

So we can remove the temporary listener code and move on.

Adding the Page Flip rotation code

Earlier on we said that we would use a AnimatedBuilder to get the effect we want.

While we could return an AnimatedBuilder directly in our build() method, we should follow the single responsibility principle and create a separate widget instead.

So let's do this:

class AnimatedPageFlipBuilder extends StatelessWidget { const AnimatedPageFlipBuilder({ Key? key, required this.animation, required this.showFrontSide, required this.frontBuilder, required this.backBuilder, }) : super(key: key); final Animation<double> animation; final bool showFrontSide; // we'll see how to use this later final WidgetBuilder frontBuilder; final WidgetBuilder backBuilder; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: animation, builder: (context, _) { // TODO: implement me }, ); } }

This new widget class takes the same frontBuilder and backBuilder arguments as our PageFlipBuilder.

But it also has an Animation<double> animation variable that is passed as an input to the AnimatedBuilder in the build() method.

With this in place, let's update the build() method of PageFlipBuilderState:

@override Widget build(BuildContext context) { return AnimatedPageFlipBuilder( animation: _controller, showFrontSide: _showFrontSide, frontBuilder: widget.frontBuilder, backBuilder: widget.backBuilder, ); }

As we can see, the _controller is passed to the animation argument, which has type Animation<double>. This is allowed because AnimationController is defined like this in the Flutter SDK:

class AnimationController extends Animation<double> {}

Rotation code with Transform and Matrix4

We will update the AnimatedBuilder code in a minute.

But first, let's work out some math.

With our current setup, our animation always has a value between 0.0 and 1.0 and this corresponds to a rotation value between 0.0 and pi (180 degrees).

To get the page effect we want, we have to:

  • show the front page for animation values between 0.0 and 0.5
  • show the back page for animation values between 0.5 and 1.0

To accomplish this, we need the following code inside the AnimatedBuilder:

AnimatedBuilder( animation: animation, builder: (context, _) { // this boolean tells us if we're on the first or second half of the animation final isAnimationFirstHalf = animation.value.abs() < 0.5; // decide which page we need to show final child = isAnimationFirstHalf ? frontBuilder(context) : backBuilder(context); // map values between [0, 1] to values between [0, pi] final rotationValue = animation.value * pi; // calculate the correct rotation angle depening on which page we need to show final rotationAngle = animation.value > 0.5 ? pi - rotationValue : rotationValue; return Transform( transform: Matrix4.rotationY(rotationAngle), child: child, alignment: Alignment.center, ); }, )

There is a lot to unpack here:

  • we use isAnimationFirstHalf to decide which builder to call (front or back) and get the child widget that will be rendered
  • we calculate the rotation angle and pass Matrix4.rotationY(rotationAngle) to a Transform widget to apply a 3D rotation around the Y axis

If we run the app now we can flip the page front and back:

Page flip effect without depth
Page flip effect without depth

Unfortunately the result lacks in "depth" and doesn't look like a 3D rotation.

To fix that we need to calculate and apply a "tilt" value.

This value should be 0.0 at the beginning and at the end of the animation. And the front and back widgets should have an opposite tilt value.

Let's put everything together:

AnimatedBuilder( animation: animation, builder: (context, _) { // this boolean tells us if we're on the first or second half of the animation final isAnimationFirstHalf = animation.value.abs() < 0.5; // decide which page we need to show final child = isAnimationFirstHalf ? frontBuilder(context) : backBuilder(context); // map values between [0, 1] to values between [0, pi] final rotationValue = animation.value * pi; // calculate the correct rotation angle depening on which page we need to show final rotationAngle = animation.value > 0.5 ? pi - rotationValue : rotationValue; // calculate tilt var tilt = (animation.value - 0.5).abs() - 0.5; // make this a small value (positive or negative as needed) tilt *= isAnimationFirstHalf ? -0.003 : 0.003; return Transform( transform: Matrix4.rotationY(rotationAngle) // apply tilt value ..setEntry(3, 0, tilt), child: child, alignment: Alignment.center, ); }, )

With the latest changes, we calculate the tilt and apply it by changing one value of the Matrix4 object via ..setEntry(3, 0, tilt).

The resulting Matrix4 that is used for our transform looks like this:

Transform matrix with Y Rotation and tilt
Transform matrix with Y Rotation and tilt

You can read this article on Matrix4 And Perspective Transformations to learn more about the maths we just used.

If we run the app now, we can see that the page flip is a lot more convincing:

Page flip with tilt effect
Page flip with tilt effect

Great stuff, we can now flip our page forwards and in reverse when the flip button is pressed.

Time to pat ourselves on the back.

Good job, my friend

Wrap Up

This completes part 1 of this tutorial.

Along the way, we have learned how to:

  • pass an AnimationController as an input to an AnimatedBuilder widget
  • use the AnimatedBuilder and Transform widgets to implement 3D rotations
  • synchronize local widget state with the AnimationController's value

And while PageFlipBuilder is quite complex on the inside, we have designed an API that makes it easy to use from the outside.

What next?

In part 2 we will make our PageFlipBuilder interactive by adding a GestureDetector.

Or if you can't wait and want to use this already in your apps, you can head over to pub.dev where I have published the final product as a Flutter package:

And of course you can also check out the source code on GitHub:

In addition to what we have covered in these tutorials, the page_flip_builder package offers some extra features:

  • flip around the horizontal or vertical axis
  • use it to flip widgets of any size
  • customizable tilt and scale parameters
Card flip transitions on the Y and X axis
Card flip transitions on the Y and X axis

If you end up using it, let me know what you think on Twitter.

Credits

These articles by the Flutter community helped me understand some of the internals needed for my PageFlipBuilder:

The flip animation article used an AnimatedSwicher widget, which makes life easier if you don't need to handle drag gestures.

But I wanted to take things further by making the animation interactive and create a package with a nice API that you can use in your apps.

One more thing

AnimationController, AnimatedBuilder, and GestureDetector are very powerful when used together.

But there is so much more you can do with animations in Flutter.

In fact I created a brand new Flutter Animations course that includes 7 hours of content. You can learn more and buy the course here:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.