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.
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:
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)
:
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
.
So our next two goals are:
- Setup an
AnimationController
to control the flip transition - 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 thechild
widget that will be rendered - we calculate the rotation angle and pass
Matrix4.rotationY(rotationAngle)
to aTransform
widget to apply a 3D rotation around the Y axis
If we run the app now we can flip the page front and back:
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:
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:
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.
Wrap Up
This completes part 1 of this tutorial.
Along the way, we have learned how to:
- pass an
AnimationController
as an input to anAnimatedBuilder
widget - use the
AnimatedBuilder
andTransform
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
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: