Flutter app development tutorials by Andrea Bizzotto

Flutter Animations: Interactive Page Flip Widget [Part 2]

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:

Page Flip with tilt effect
Page Flip with tilt effect

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:

Interaction between GestureDetector, AnimationController, and AnimatedBuilder
Interaction between GestureDetector, AnimationController, and AnimatedBuilder

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 as onHorizontalDragUpdate.
  • 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 from MediaQuery.

If we run the app now we can notice two things:

  1. 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.
  2. If we release the pointer mid-way, the page remains partially rotated and does not complete the flip animation.
Page flip with interactive drag
Page flip with interactive drag

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:

Broken matrix transform

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:

Rotation angle plot for the range (-1.0, 1.0)
Rotation angle plot for the range [-1.0, 1.0]
Tilt plot for the range (-1.0, 1.0)
Tilt plot for the range [-1.0, 1.0]

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.

Page flips two times
The page now flips twice

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:

Visible page
Visible page "switches" at the end of the animation

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.

Page flip now works correctly
Page flip now works correctly

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:

Interactive page flip: fling back when the pointer is released
Interactive page flip: fling back when the pointer is released

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:

Interactive page flip taking velocity into account
Interactive page flip taking velocity into account

If you've made all the way to this point, congratulations! You're now a Flutter animation Pro! πŸš€

Flinging it meme

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 an AnimatedBuilder widget
  • use the AnimatedBuilder and Transform widgets to implement 3D rotations
  • use a GestureDetector and work with drag gestures
  • use the AnimationController's lowerBound and upperBound 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.

I'm also creating an in-depth Flutter Animations course. You can learn more about it and sign up to get exclusive discounts here:

Flutter Animations Course

Happy coding!

Want more?

Support my work and fast-track your Flutter learning with my in-depth courses.