This is part 2 of my Flutter animations tutorial about how to build an interactive page flip widget.
In part 1 we have created a PageFlipBuilder
widget using AnimationController
, AnimatedBuilder
, and a Transform
widget to apply a 3D rotation:
But we're not done just yet as we still need to make our PageFlipBuilder
interactive.
This is going to be a tough challenge, so let's flex our coding muscles and dive in. 💪
If you want to code along, you can grab the source code from here.
Interactive Page Flip with GestureDetector
The main idea here is to use a GestureDetector
to detect horizontal drag gestures on screen and update the AnimationController
's value based on the pointer delta:
To get this working we can add a GestureDetector
as a parent to our AnimatedPageFlipBuilder
:
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: _handleDragUpdate,
child: AnimatedPageFlipBuilder(
animation: _controller,
frontBuilder: widget.frontBuilder,
backBuilder: widget.backBuilder,
showFrontSide: _showFrontSide,
),
);
}
Then, we can implement the _handleDragUpdate()
method:
void _handleDragUpdate(DragUpdateDetails details) {
final screenWidth = MediaQuery.of(context).size.width;
_controller.value += details.primaryDelta! / screenWidth;
}
A few things to note:
details.primaryDelta
tells us how much the pointer has moved along the primary axis. It is a nullable variable but we can safely use!
because it is always non-null for one-dimensional drag gestures such asonHorizontalDragUpdate
.- Our controller's value can range from 0.0 to 1.0. We want to map this to the screen width so that dragging from one side to the other completes exactly one flip. Hence we divide the
primaryDelta
by the screen width that we get fromMediaQuery
.
If we run the app now we can notice two things:
- The interactive drag works when we drag from the left to the right (and back). But if we start from the front page and try to drag to the left, nothing happens.
- If we release the pointer mid-way, the page remains partially rotated and does not complete the flip animation.
Let's tackle one problem at a time.
1. Revisiting the AnimationController and AnimationBuilder code
Our problem is that our AnimationController
's value default range goes from 0.0 to 1.0.
When we drag to the left, details.primaryDelta
will have a negative value but the controller's value will not go below 0.0.
To account for this we can explicitly set the lowerBound
and upperBound
of the AnimationController
:
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
// lowerBound of -1.0 is needed for the back flip
lowerBound: -1.0,
// upperBound of 1.0 is needed for the front flip
upperBound: 1.0,
);
If we hot-restart now, we are welcomed with a very stretched page:
What's going on here?
Well, we have a new problem because our matrix transform code was only designed to work in the range 0.0 to 1.0. But for values less than -0.5 things get wonky. 😅
And now we need to handle all animation values between -1.0 and 1.0.
So let's fix this. To make things a bit more readable, let's create separate methods to calculate the tilt and rotationAngle values inside our AnimatedPageFlipBuilder
class:
bool get _isAnimationFirstHalf => animation.value.abs() < 0.5;
double _getTilt() {
var tilt = (animation.value - 0.5).abs() - 0.5;
if (animation.value < -0.5) {
tilt = 1.0 + animation.value;
}
return tilt * (_isAnimationFirstHalf ? -0.003 : 0.003);
}
double _rotationAngle() {
final rotationValue = animation.value * pi;
if (animation.value > 0.5) {
return pi - rotationValue; // input from 0.5 to 1.0
} else if (animation.value > -0.5) {
return rotationValue; // input from -0.5 to 0.5
} else {
return -pi - rotationValue; // input from -1.0 to -0.5
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final child = _isAnimationFirstHalf
? frontBuilder(context)
: backBuilder(context);
return Transform(
transform: Matrix4.rotationY(_rotationAngle())
..setEntry(3, 0, _getTilt()),
child: child,
alignment: Alignment.center,
);
},
);
}
The _rotationAngle()
and _getTilt()
methods are now correct for animation values between -1.0 and 1.0. Here are some 2D plots showing how they work:
If we hot-reload now and try things out we notice that the interactive drag works in both directions.
But we still have one small problem: when we press the flip button the page flips two times, not one.
This is because the AnimationController
's value now goes from -1.0 to 1.0, which corresponds to rotation angles betweem -pi
and pi
.
Ideally, when we flip the page programmatically we want the animation value to start from 0.0, and go forward to 1.0 or reverse to -1.0. This will ensure that we rotate the page by pi
rather than 2 * pi
.
To accomplish this, we need to make some changes.
First, let's make sure that the AnimationController
's value is initialized to 0.0 in initState()
:
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: widget.nonInteractiveAnimationDuration,
// lowerBound of -1.0 is needed for the back flip
lowerBound: -1.0,
// upperBound of 1.0 is needed for the front flip
upperBound: 1.0,
);
// Start from 0.0. This needs to be explicit because the lowerBound is now -1.0
_controller.value = 0.0;
_controller.addStatusListener(_updateStatus);
super.initState();
}
Then, let's update the _updateStatus
to reset the value to 0.0 when the animation is complete:
void _updateStatus(AnimationStatus status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
// The controller always completes a forward animation with value 1.0
// and a reverse animation with a value of -1.0.
// By resetting the value to 0.0 and toggling the state
// we are preparing the controller for the next animation
// while preserving the widget appearance on screen.
_controller.value = 0.0;
setState(() => _showFrontSide = !_showFrontSide);
}
}
This ensures that the controller is ready for the next animation. But it has one side effect causing the page to "switch" from front to back when the animation completes:
So the last step is to use the showFrontSide
variable to choose the correct child in the AnimatedBuilder
code:
final child = _isAnimationFirstHalf ^ showFrontSide
? backBuilder(context)
: frontBuilder(context);
Here we use the XOR operator (^
) to choose the backBuilder
or frontBuilder
depending on the value of _isAnimationFirstHalf ^ showFrontSide
.
XOR is also known as Exclusive or. This Wikipedia page explains how it works.
If we hot-restart now, we can see that both the programmatic and interactive page flip work as intended.
We're nearly done and we just need to tackle one last problem:
If we release the pointer mid-way, the page remains partially rotated and does not complete the flip animation.
Ready for the final stretch?
Let's fling that AnimationController!
How can we tell our AnimationController
to complete the animation if we release the pointer mid-way?
The trick is to use the fling()
method:
_controller.fling(velocity: velocity);
By calling this, the animation will complete forwards if we pass a positive velocity
value, or in reverse if we pass a negative velocity
value.
So let's start with a simplified implementation that gets this done.
First, we can add a new onHorizontalDragEnd
argument to our GestureDetector
:
return GestureDetector(
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
child: AnimatedPageFlipBuilder(
animation: _controller,
frontBuilder: widget.frontBuilder,
backBuilder: widget.backBuilder,
showFrontSide: _showFrontSide,
),
);
Then we can define the _handleDragEnd()
method like this:
void _handleDragEnd(DragEndDetails details) {
// If the controller is currently animating or has already completed, do nothing
if (_controller.isAnimating ||
_controller.status == AnimationStatus.completed ||
_controller.status == AnimationStatus.dismissed) return;
// calculate the currentVelocity based on the drag velocity and screen width
final screenWidth = MediaQuery.of(context).size.width;
final currentVelocity = details.velocity.pixelsPerSecond.dx / screenWidth;
// if value and velocity are 0.0, the gesture was a tap so we return early
if (_controller.value == 0.0 && currentVelocity == 0.0) {
return;
}
const flingVelocity = 2.0;
if (_controller.value > 0.5) {
_controller.fling(velocity: flingVelocity);
} else if (_controller.value < -0.5) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > 0.0) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > -0.5) {
_controller.fling(velocity: flingVelocity);
}
}
The first part of the method checks some conditions and returns early if there is nothing to do.
The second part is most interesting. The main idea here is to divide the entire animation range into four sub-ranges and fling the controller to the desired animation value:
- 0.5 to 1.0 → fling forwards to 1.0
- 0.0 to 0.5 → fling backwards to 0.0
- -0.5 to 0.0 → fling forwards to 0.0
- -1.0 to -0.5 → fling backwards to -1.0
Unfortunately, it's not possible to fling to 0.0 because the controller will overshoot and go all the way to -1.0 or 1.0.
Fear not, as we can "hack" things by changing the controller value and the _showFrontSide
variable at the same time:
if (_controller.value > 0.5) {
_controller.fling(velocity: flingVelocity);
} else if (_controller.value < -0.5) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > 0.0) {
// controller can't fling to 0.0 because the lowerBound is -1.0
// so we decrement the value by 1.0 and toggle the state to get the same effect
_controller.value -= 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > -0.5) {
// controller can't fling to 0.0 because the upperBound is 1.0
// so we increment the value by 1.0 and toggle the state to get the same effect
_controller.value += 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: flingVelocity);
}
If we run the app now, we can see that the page flings back to the nearest non-fractional animation value:
This works, but it would be even better if we could "flick" the page to the other side if the flingVelocity
is greater than the velocityThreshold
.
So here's the final version of the "flinging" code:
if (_controller.value > 0.5 ||
_controller.value > 0.0 && currentVelocity > flingVelocity) {
_controller.fling(velocity: flingVelocity);
} else if (_controller.value < -0.5 ||
_controller.value < 0.0 && currentVelocity < -flingVelocity) {
_controller.fling(velocity: -flingVelocity);
} else if (_controller.value > 0.0 ||
_controller.value > 0.5 && currentVelocity < -flingVelocity) {
// controller can't fling to 0.0 because the lowerBound is -1.0
// so we decrement the value by 1.0 and toggle the state to get the same effect
_controller.value -= 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: -currentVelocity);
} else if (_controller.value > -0.5 ||
_controller.value < -0.5 && currentVelocity > flingVelocity) {
// controller can't fling to 0.0 because the upperBound is 1.0
// so we increment the value by 1.0 and toggle the state to get the same effect
_controller.value += 1.0;
setState(() => _showFrontSide = !_showFrontSide);
_controller.fling(velocity: flingVelocity);
}
With this in place we can hot-reload the app one last time and have some fun:
If you've made all the way to this point, congratulations! You're now a Flutter animation Pro! 🚀
Conclusion
We have now completed our interactive page flip widget.
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 - use a
GestureDetector
and work with drag gestures - use the
AnimationController
'slowerBound
andupperBound
in practice - synchronize local widget state with the
AnimationController
's value - use the
fling()
method to complete an animation
You can use PageFlipBuilder
to add a touch of magic and make your apps more fun to play with.
I published PageFlipBuilder on pub.dev so you can easily install it and use it to flip pages, cards or widgets of any size.
If you want to learn more about animations, the Flutter documentation includes a very good introduction.
And if you want to go more in depth, I created a brand new Flutter Animations course that includes 7 hours of content. You can learn more and buy the course here: