Throw the ball flutter animation
Introduction
The idea of this tutorial is to build a Flutter "Throw the Ball" animation using GestureDetector to emulate the thrown action, FrictionSimulation to slow down the ball until stops and CustomPainter to draw the ball with high performance.
First off, Flutter geometry 📐
Was very important to me in order to achieve my goal to deeply understand how Flutter implement geometry.
The class Offset(x, y) represent a point on the canvas that allow you to access to the dx, and dy properties which, as you imagine, are distance from the coordinate origin. However, it can be used as a vector as well because it has distance and direction properties.
Let's take a look the next image to better understand the concepts:
As you may see, the Y axis is inverted in Flutter coordinate system.
Another very important concept to understand is how flutter take the angles values. These values don't go from 0 to 2π, they go from 0 to π for positive Y and from 0 to -π for negative Y. Let's clarify this with a picture:
If you are a good observer you might noted that π = -π.
Create the ball using CustomPainter ⚾
The first step, after understanding how Flutter geometrics works, is to draw a circle on the screen center. Not a big deal, you can find a lot of code throughout the internet to achieve it. This is the code I've wrote:
import 'package:flutter/material.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final bool _needRepaint = false;
Offset? _centerOffset;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Builder(builder: (context) {
// set the canvas size to the whole screen
final canvasSize = MediaQuery.of(context).size;
// calculates the center of the screen
_centerOffset ??= Offset(canvasSize.width / 2, canvasSize.height / 2);
// paints the circle placed in the center of the given canvas
final circleShape = CircleShape(
needRepaint: _needRepaint,
center: _centerOffset!,
radius: 25,
);
return Scaffold(
body: Center(
child: SizedBox(
height: canvasSize.height,
width: canvasSize.width,
child: ColoredBox(
color: Colors.amber,
child: CustomPaint(
foregroundPainter: circleShape,
size: canvasSize,
),
),
),
),
);
}),
);
}
}
/// Class to draw the circle with a given [center] offset and [radius]. The
/// parameter [needRepaint] is needed to improve performance and not been
/// rebuilding the UI in ever frame
class CircleShape extends CustomPainter {
final bool needRepaint;
final Offset center;
final double radius;
CircleShape({
required this.needRepaint,
required this.center,
required this.radius,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xff995588)
..style = PaintingStyle.fill;
canvas.drawCircle(
center,
radius,
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => needRepaint;
}
The _needRepaint variable is used for further implementation, don't pay attention to it right now. This code will produce the following outcome:
Making the ball pickable and draggable ⚾✋
In order to make the ball pickable and draggable we need to wrap the ColoredBox with a GestureDetector widget and write functions for the onPanStart and onPanUpdate callbacks to check if the user hits the ball and setting the new circle center if the ball is dragged:
GestureDetector(
onPanStart: (details) {
// pick the ball only if the animation is not
// in progress and the user hits upon the ball
if (_isAnimationInProgress == false &&
(circleShape.hitTest(details.localPosition) ?? false)) {
setState(() {
_needRepaint = true;
});
}
},
onPanUpdate: (details) {
// prevent to set a new center position for hits out of
// the ball bounds and if the animation is in progress as well
if (_isAnimationInProgress == false && _needRepaint) {
setState(() {
_centerOffset = details.localPosition;
});
}
},
child: ColoredBox(
color: Colors.amber,
child: CustomPaint(
foregroundPainter: circleShape,
size: canvasSize,
),
),
We must to declare the _isAnimationInProgress class variable:
bool _isAnimationInProgress = false;
Finally, we need to override the hitTest method on the CircleShape class to check if the user click is onto the circle.
@override
bool? hitTest(Offset position) {
// Calculate the distance between the position and the center of the circle
final distance = (position - center).distance;
// Check if the distance is within the radius of the circle
if (distance <= radius) {
return true;
} else {
return false;
}
}
The physical animation when the ball is dropped ⚾💨
At this point, when the user release the mouse button, the ball stands on the same location. In order simulate the throw ball movement we need to get the speed and direction at the time the user drop the object which is provided by onPanStop callback through the Offset property details.velocity. pixelPerSecond, hence we will be able to get the properties distance and direction.
Recommended by LinkedIn
In this way, having the initial velocity, we can leverage on the Friction Animation class provided by the framework to perform the desire deceleration movement. The following code will do the trick.
The onPanEnd callback on GestureDetector widget:
onPanEnd: (details) {
// prevent to set a new center position for hits out of
// the ball bounds and if the animation is in progress as well
if (_isAnimationInProgress == false && _needRepaint) {
_runAnimation(
details.velocity.pixelsPerSecond,
const Size(50, 50),
canvasSize,
);
}
},
The _runAnimation method, which receive the velocity vector pixelPerSecond, the objectSize and the canvasSize will create the FrictionSimulation object. The objectSize and canvasSize will be used ahead to bounce the ball on the canvas bounds. The code will look like this:
/// Calculates and runs a [SpringSimulation]
void _runAnimation(
Offset pixelsPerSecond,
Size objectSize,
Size canvasSize,
) {
_isAnimationInProgress = true;
// create an unbounded because physis simulations don't have bounds
final controller = AnimationController.unbounded(
vsync: this,
);
final velocityPixelsPerSecond = pixelsPerSecond.distance;
// creates a FrictionSimulation. The drag parameter goes from 1 (infinite
// friction) to 0 (none friction). The position parameter tell the flutter
// engine in witch point should start to refresh the UI; e.g. if the
// velocity is 100 pixel/sec, position is 200 and none friction will start
// to send updates on the 2nd sec
final simulation = FrictionSimulation(0.05, 0, velocityPixelsPerSecond);
// the angle in radians from 0 to pi for +y and 0 to -pi for -y
var direction = pixelsPerSecond.direction;
// as the controller always increment the value this variable is needed
// to get the differential increment
var walkedDistance = 0.0;
controller.addListener(() {
setState(() {
// differential offset is the incremental point from the last frame
final differentialOffset =
Offset.fromDirection(direction, controller.value - walkedDistance);
// calculates the new center with the given increment
_centerOffset = Offset(_centerOffset!.dx + differentialOffset.dx,
_centerOffset!.dy + differentialOffset.dy);
// update walkedDistance to get the differentialOffset in next frame
walkedDistance = controller.value;
});
});
// run the animation and dispose the controller when finish
controller.animateWith(simulation).whenComplete(() {
setState(() {
_needRepaint = false;
_isAnimationInProgress = false;
controller.dispose();
});
});
}
FrictionSimulation object requires 3 parameter according the docs:
- double drag: fluid drag coefficient in the range from 0 (infinite friction) to 1 (none friction).
- double position: the initial position corresponding to the first retrieved value from controller.value property.
- double velocity: initial velocity which is self-explanatory.
The controller.value gives you the total distance walked and for the furthers calculations it's way better to have the differential distance. It simplifies the bouncing angle calculations which is what we are going to do next. This is what we built so far:
Bounce the ball onto canvas margins 🚧
Last and not least, we will add the bounce effect which can be achieved by changing the direction angle from the moving ball.
For the X axis we have:
You can figure out the exit angle 𝞫 = 𝞹 - 𝞪. In the other hand, for the Y axis we have:
In this case 𝞫 = - 𝞪.
Moving the theory to the practice, I added the bouncing angles changes to the listener call considering the ball perimeter:
controller.addListener(() {
setState(() {
// differential offset is the incremental point from the last frame
final differentialOffset =
Offset.fromDirection(direction, controller.value - walkedDistance);
// calculates the new center with the given increment
_centerOffset = Offset(_centerOffset!.dx + differentialOffset.dx,
_centerOffset!.dy + differentialOffset.dy);
// update walkedDistance to get the differentialOffset in next frame
walkedDistance = controller.value;
// check if should bounce on the canvas left bound
if (_centerOffset!.dx - objectSize.width / 2 < 0) {
direction = pi - direction;
_centerOffset = Offset(
objectSize.width / 2,
_centerOffset!.dy,
);
}
// check if should bounce on the canvas top bound
if (_centerOffset!.dy - objectSize.height / 2 < 0) {
direction = -direction;
_centerOffset = Offset(
_centerOffset!.dx,
objectSize.height / 2,
);
}
// check if should bounce on the canvas right bound
if (_centerOffset!.dx + objectSize.width / 2 > canvasSize.width) {
direction = pi - direction;
_centerOffset = Offset(
canvasSize.width - objectSize.width / 2,
_centerOffset!.dy,
);
}
// check if should bounce on the canvas bottom bound
if (_centerOffset!.dy + objectSize.height / 2 > canvasSize.height) {
direction = -direction;
_centerOffset = Offset(
_centerOffset!.dx,
canvasSize.height - objectSize.height / 2,
);
}
});
}
The final result:
The complete code can be found here.
Conclusion
Physical animations was always a pending task in my developer career. I've been in many jobs working hard in Flutter but never got the change work with physical animations and understand the process and all the involved pieces, not to mentioning the Geometry concepts. In spite that you can achieve the same and even better results using a gaming library, the juicy learnings got in the process were very rich for my developer career. As an electronic engineer I always have the need to understand how the things works under the hood.
Devoted to my wife who's ever there holding me up when I fall apart