In this paper, the author: Didier Boelens the original link: www.didierboelens.com/2018/06/ani… Translation: HCC
Difficulty: Intermediate
Today, we can’t imagine mobile apps without any animations when you jump from page to page, or click a button (like InkWell)… There’s always an animation. Animation is everywhere.
Flutter makes animation very easy to implement.
In a nutshell, this is the topic that this post is about, and although it’s only been discussed by experts before, to make it more compelling, I’m going to challenge Vitaly Rubtsov to copy an animation of a “Guillotine Menu “uploaded by Dribble. Achieve this effect step by step with Flutter.
The first part of this paper will introduce the main theoretical knowledge and concepts, and the second part will realize the animation effect above.
Three cores in animation
In order to be able to animate, the following three elements must be provided:
- Ticker
- Animation
- AnimationController
These elements are briefly described below and explained in more detail below.
Ticker
In simple terms, the Ticker class sends a signal over a regular interval of time (about 60 times per second). Think of it as your watch ticking every second.
When the Ticker is started, each incoming tick calls the Ticker’s callback method since the first tick arrives.
Important note Although all tickers may start at different times, they are always executed synchronously, which is useful for some synchronous animations.
Animation
Animation is nothing special, it is just a value that can change with the life cycle of the Animation (there is a specific type). The value can change with the Animation time in a linear way (e.g. 1, 2, 3, 4, 5…). Or more complex (see “Curves” below).
AnimationController
AnimationController is a controller that can control one or more animations (start, end, repeat). In other words, it makes the Animation value change from a minimum to a maximum at a given speed over a specified period of time.
AnimationController class introduction
This class can control animation. To be more precise, I prefer to say “control a scene” because, as we’ll see later, several different animations can be controlled by the same controller…
So, using the AnimationController class, we can:
- Start a subanimation, playing forward or backward
- Stops a subanimation
- Sets a specific value for the child animation
- Defines the boundaries of the animation values
The following pseudocode shows the different initialization parameters in this class
AnimationController controller = new AnimationController(
value: // the current value of the animation, usually 0.0 (= default)
lowerBound: // the lowest value of the animation, usually 0.0 (= default)
upperBound: // the highest value of the animation, usually 1.0 (= default)
duration: // the total duration of the whole animation (scene)
vsync: // the ticker provider
debugLabel: // a label to be used to identify the controller
// during debug session
);
Copy the code
In most cases, value, lowerBound, upperBound, and debugLabel are not designed when AnimationController is initialized.
How to bind AnimationController to Ticker
In order for the animation to work, the AnimationController must be bound to the Ticker.
Typically, you can generate a Ticker bound to a StatefulWidget instance.
class _MyStateWidget extends State<MyStateWidget> with SingleTickerProviderStateMixin { AnimationController _controller; @override voidinitState(){
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
}
@override
void dispose(){ _controller.dispose(); super.dispose(); }... }Copy the code
-
The second line tells Flutter that you want a single Ticker, which is linked to the MyStateWidget instance.
-
8-10 lines
Initialization of the controller. The total duration of the scene (subanimation) is set to 1000 ms and is bound to Ticker (vsync: this).
Implicit arguments are lowerBound = 0.0 and upperBound = 1.0
- 16 rows
It is important that you release the Controller when the instance of the MyStateWidget page is destroyed.
TickerProviderStateMixin or SingleTickerProviderStateMixin?
If you have several Animation Controller case, you want to have a different Ticker, just need to replace for TickerProviderStateMixin SingleTickerProviderStateMixin.
Okay, I’ve tied the controller to the Ticker, but it works, okay?
Due to the Ticker, approximately 60 ticks will be generated per second, and the AnimationController will generate a linear value between the minimum and maximum value of the tick at a given time.
An example of a value generated in these 1000 milliseconds is as follows:
We see the value change from 0.0 (lowerBound) to 1.0 (upperBound) in 1000 milliseconds. Fifty-one different values were generated.
Let’s extend the code to see how to use it.
class _MyStateWidget extends State<MyStateWidget> with SingleTickerProviderStateMixin { AnimationController _controller; @override voidinitState(){
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_controller.addListener((){
setState((){});
});
_controller.forward();
}
@override
void dispose(){ _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context){final int percent = (_controller.value * 100.0).round();return new Scaffold(
body: new Container(
child: new Center(
child: new Text('$percent%'),),),); }}Copy the code
-
Line 12 tells the controller that each time its value changes, we need to rebuild the Widget (via setState ())
-
Line 15
After the Widget is initialized, we tell the controller to start counting (forward () -> from lowerBound to upperBound)
- 26 line
We retrieve the value of the controller (_controller.value), and in this example, the value ranges from 0.0 to 1.0 (that is, 0% to 100%), and we get an integer expression for this percentage, which we display in the center of the page.
The concept of animation
As we have seen, controllers can return small values that differ from each other in a linear fashion.
Sometimes we may have other needs such as:
- Use other types of values, such as Offset, int…
- Use range is not from 0.0 to 1.0
- Consider other types of change than linear change to produce some effect
Use other value types
To be able to use other value types, the Animation class uses templates.
In other words, you can define:
Animation<int> integerVariation;
Animation<double> decimalVariation;
Animation<Offset> offsetVariation;
Copy the code
Use different numerical ranges
Sometimes we want to use a different range than 0.0 and 1.0.
To define such a scope, we will use the Tween class.
To illustrate this point, let’s consider a case where you want the Angle to change from 0 to PI / 2.
Animation<double> angleAnimation = new Tween(begin: 0.0, end: PI /2);Copy the code
Change the type
As mentioned earlier, the default way to change the default value from lowerBound to upperBound is linear, which is how controllers are controlled.
To change the Angle linearly from 0 to PI / 2 radians, bind the Animation to the AnimationController:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: PI /2). Animate (_controller);Copy the code
When you start the animation (via _controller.forward ()), angleanimation.value uses _Controller.value to get the value in the range [0.0; π/ 2].
The graph below shows this linear variation (π/ 2 = 1.57)
Use the predefined curve changes of Flutter
The Curved changes provided by Flutter are as follows:
To use these curve effects:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: PI /2). Animate (new CurvedAnimation(parent: _controller, curve: Curves.ease, reverseCurve: Curves.easeOut ));Copy the code
This yields values between the values [0; π/ 2] :
- When animation is played forward with values from 0 to π/2, the Curves. Ease effect is used
- The Curves. EaseOut effect is used when the animation is played backwards, with values ranging from PI /2 to 0
Control the animation
The AnimationController class lets you control animations through the API. (Here are the most commonly used apis) :
- _controller.forward ({values of both ranges})
Ask the controller to start generating values in lowerBound- > upperBound
The optional argument to from can be used to force the controller to “count” from a value other than lowerBound
- _controller.reverse ({values of two intervals})
Ask the controller to start generating values in upperBound- > lowerBound
The optional argument to from can be used to force the controller to “count” from a value other than “upperBound”
- _controller.stop ({bool cancelled: true})
Stop animation
- _controller. Reset ()
Reset the animation to start from LowerBound
- _controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })
Changes the current value of the animation to the target value.
- _controller.repeat ({double min, double Max, Duration period})
Start the animation forward and restart it when it is finished. If min or Max is defined, the number of iterations of the animation is limited.
Be on the safe side
Since the animation can stop unexpectedly (for example by closing the screen), it is safer to add “.orcancel “when using one of the following apis:
__controller.forward().orCancel;
Copy the code
This trick ensures that no exception will be raised if the Ticker is cancelled before _controller is released.
Concept of scene
The word “scenario” doesn’t exist in the official documentation, but personally, I find it closer to reality. Let me explain.
As I said, an AnimationController manages an Animation. However, we might understand the term “animation” to mean a series of sub-animations that need to be played in sequence or overlapping. Putting the sub-animations together is what I call a scene.
Consider the following case, where the entire duration of the animation is 10 seconds, and what we want to achieve is:
- In the first 2 seconds, a ball moves from the left side of the screen to the center of the screen
- The same ball then takes three seconds to move from the center of the screen to the top center of the screen
- Ultimately, it takes five seconds for the ball to disappear. As you can most probably imagine, we had to consider three different animations:
/// /// Definition of the _controller with a whole duration of 10 seconds /// AnimationController _controller = new AnimationController( duration: const Duration(seconds: 10), vsync: this ); /// /// First animation that moves the ball from the left to the center /// Animation<Offset> moveLeftToCenter = new Tween(begin: new Offset(0.0, screenHeight /2), end: new Offset(screenWidth /2, screenHeight /2) ).animate(_controller); /// /// Second animation that moves the ball from the center to the top /// Animation<Offset> moveCenterToTop = new Tween(begin: new Offset(screenWidth /2, screenHeight /2), end: new Offset(screenWidth /2, 0.0)). Animate (_controller); /// /// Third animation that will be used to change the opacity of the ball to make it disappear /// Animation<double> Disappear = new Tween(begin: 1.0, end: 0.0).animate(_controller);Copy the code
The question now is, how do we link (or orchestrate) sub-animations?
Interval
Compositing animations can be done through the Interval class. But what is an Interval?
Unlike what might first come to mind, an Interval is not about time, but a range of values.
If you consider using _controller, you must remember that it changes the value from lowerBound to upperBound.
In general, these two values are basically defined as lowerBound = 0.0 and upperBound = 1.0, which makes animation calculations easier because [0.0-> 1.0] is just a change from 0% to 100%. Therefore, if the total duration of a scenario is 10 seconds, it is most likely that after 5 seconds, the corresponding _controller.value will be very close to 0.5 (= 50%).
If you put three different animations on one timeline, you get the following schematic:
If we now consider the spacing of values, then for each of the three animations we would get:
- moveLeftToCenter
Duration: 2 seconds, starting at 0 seconds and ending at 2 seconds => Range = [0; 2] => Percentage: from 0% to 20% of the entire scene => [0.0; 0.20]
- moveCenterToTop
Duration: 3 seconds, start at 2 seconds, end at 5 seconds => Range = [2; 5] => Percentage: from 20% to 50% of the entire scene => [0.20; 0.50]
- disappear
Duration: 5 seconds, start at 5 seconds, end at 10 seconds => Range = [5; 10] => Percentage: from 50% to 100% of the entire scene => [0.50; 1.0]
Now that we have these percentages, we get the definition for each animation as follows:
/// /// Definition of the _controller with a whole duration of 10 seconds /// AnimationController _controller = new AnimationController( duration: const Duration(seconds: 10), vsync: this ); /// /// First animation that moves the ball from the left to the center /// Animation<Offset> moveLeftToCenter = new Tween(begin: new Offset(0.0, screenHeight /2), end: new Offset(screenWidth /2, screenHeight /2) ).animate( new CurvedAnimation( parent: _controller, curve: New Interval(0.0, 0.20, Curve: linear,),); /// /// Second animation that moves the ball from the center to the top /// Animation<Offset> moveCenterToTop = new Tween( begin: new Offset(screenWidth /2, screenHeight /2), end: New Offset(screenWidth /2, 0.0). Animate (new CurvedAnimation(parent: _controller, curve: New Interval(0.20, 0.50, Curve: Curves. Linear,),); /// /// Third animation that will be used to change the opacity of the ball to make it disappear /// Animation<double> Disappear = new Tween(begin: 1.0, end: 0.0). Animate (new CurvedAnimation(parent: _controller, curve: New Interval(0.50, 1.0, Curve: Curves. Linear,),);Copy the code
This is all you need to define a scene (or series of animations). Of course, there’s nothing to stop you from overlapping subanimations…
Response animation state
Sometimes it’s convenient to get the state of the animation (or scene).
An animation can have four different states:
- Dismissed: The animation stops after it begins (or has not yet started)
- Forward: The animation runs from beginning to end
- Reverse: The animation is played in reverse
- Completed: The animation stopped after it played
To obtain this state, we need to listen for changes in the animation state by:
myAnimation.addStatusListener((AnimationStatus status){
switch(status){
case AnimationStatus.dismissed:
...
break;
case AnimationStatus.forward:
...
break;
case AnimationStatus.reverse:
...
break;
case AnimationStatus.completed:
...
break; }});Copy the code
A typical example of a state application is a state switch. For example, after the animation is done, we want to reverse it, as in:
myAnimation.addStatusListener((AnimationStatus status){
switch(status){
///
/// When the animation is at the beginning, we force the animation to play
///
case AnimationStatus.dismissed:
_controller.forward();
break;
///
/// When the animation is at the end, we force the animation to reverse
///
case AnimationStatus.completed:
_controller.reverse();
break; }});Copy the code
Theory is enough, now let’s get to the real thing
I mentioned an animation at the beginning of this article, and now I’m ready to implement it, called “Guillotine.”
Animation analysis and program initialization
In the future, we need to consider the following aspects:
- Page content itself
- When we click on the menu icon, the menu bar rotates
- When rotated, the menu overlays the page content and fills the entire viewport
- Once the menu is fully visible, we click on the icon again and the menu rotates out in order to return to its original position and size
From these observations we can immediately conclude that we did not use a common Scaffold with AppBar because the latter is fixed.
We need to use two layers of Stack:
- Page content (lower level)
- Menu (upper level)
The basic framework of the program is basically out:
class MyPage extends StatefulWidget {
@override
_MyPageState createState() => new _MyPageState();
}
class _MyPageState extends State<MyPage>{
@override
Widget build(BuildContext context){
return SafeArea(
top: false,
bottom: false,
child: new Container(
child: new Stack(
alignment: Alignment.topLeft,
children: <Widget>[
new Page(),
new GuillotineMenu(),
],
),
),
);
}
}
class Page extends StatelessWidget {
@override
Widget build(BuildContext context){
returnNew Container(padding: const EdgeInsets. Only (top: 90.0), color: color (0xFF222222),); } } class GuillotineMenu extends StatefulWidget { @override _GuillotineMenuState createState() => new _GuillotineMenuState(); } class _GuillotineMenuState extends State<GuillotineMenu> { @overrride Widget build(BuildContext context){returnnew Container( color: Color(0xff333333), ); }}Copy the code
The code results in a black screen, showing only the GuillotineMenu that covers the entire viewport.
Menu Effect analysis
If you look at the example above, you can see that when the menu is fully open, it completely covers the viewport. When opened, only AppBa is visible.
What if GuillotineMenu were initially rotated and rotated PI / 2 when the menu button was pressed, as shown below?
We can then rewrite the _GuillotineMenuState class as follows :(I won’t explain layout here, that’s beside the point)
Class _GuillotineMenuState extends State<GuillotineMenu> {double rotationAngle = 0.0; @override Widget build(BuildContext context){ MediaQueryData mediaQueryData = MediaQuery.of(context); double screenWidth = mediaQueryData.size.width; double screenHeight = mediaQueryData.size.height;returnRotate (Angle: rotationAngle, origin: new Offset(24.0, 56.0), alignment: alignment. TopLeft, child: Material( color: Colors.transparent, child: Container( width: screenWidth, height: screenHeight, color: Color(0xFF333333), child: new Stack( children: <Widget>[ _buildMenuTitle(), _buildMenuIcon(), _buildMenuContent(), ], ), ), ), ); } /// /// Menu Title /// Widget_buildMenuTitle() {returnNew toy (top: 32.0, left: 40.0, width: screenWidth, height: 24.0, child: new Transform. Rotate (Alignment: Alignment. TopLeft, Origin: Offset. Zero, Angle: PI / 2.0, Child: new Center(Child: new Container(width: Double. Infinity, height: double. Infinity, child: new Opacity(Opacity: 1.0, child: new Text)'ACTIVITY', textAlign: Textalign.center, style: new TextStyle(color: Colors. White, fontSize: 20.0, fontWeight: FontWeight. Bold, letterSpacing: 2.0,),),),))); } /// /// Menu Icon /// Widget_buildMenuIcon() {returnNew toy (top: 32.0, left: 4.0, Child: new IconButton(icon: const icon (Icons. Menu, color: Colors.white, ), onPressed: (){}, ), ); } /// /// Menu content /// Widget_buildMenuContent(){
final List<Map> _menus = <Map>[
{
"icon": Icons.person,
"title": "profile"."color": Colors.white,
},
{
"icon": Icons.view_agenda,
"title": "feed"."color": Colors.white,
},
{
"icon": Icons.swap_calls,
"title": "activity"."color": Colors.cyan,
},
{
"icon": Icons.settings,
"title": "settings"."color": Colors.white,
},
];
returnNew Padding(Padding: const EdgeInsets. Only (left: 64.0, top: 96.0), child: new Container(width: double.infinity, height: double.infinity, child: new Column( mainAxisAlignment: MainAxisAlignment.start, children: _menus.map((menuItem) {return new ListTile(
leading: new Icon(
menuItem["icon"],
color: menuItem["color"],
),
title: new Text(
menuItem["title"],
style: new TextStyle(
color: menuItem["color"], fontSize: 24.0),),); }).toList(), ), ), ); }}Copy the code
- Line 10 to 13
These lines define the rotation of the guillotine menu around the rotation center (the position of the menu icon)
The result of this code now displays an unrotated menu screen (because rotationAngle = 0.0) that shows the vertical title.
Next animate menu
If you update the value of rotationAngle (between -π/ 2 and 0), you’ll see that the menu rotates the corresponding Angle.
As mentioned, we need
- A SingleTickerProviderStateMixin, because we have only one scene
- A AnimationController
- An animation has an Angle change
The code looks like this:
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
///
/// Menu Icon, onPress() handling
///
_handleMenuOpenClose(){
animationControllerMenu.forward();
}
@override
void initState(){ super.initState(); /// /// Initialization of the animation controller /// animationControllerMenu = new AnimationController( duration: const Duration(milliseconds: 1000), vsync: this ).. addListener((){setState((){}); }); /// /// Initialization of the menu appearance animation // _rotationAnimation = new Tween(begin: -pi/2.0, end: 0.0). The animate (animationControllerMenu); } @override voiddispose(){
animationControllerMenu.dispose();
super.dispose();
}
@override
Widget build(BuildContext context){
MediaQueryData mediaQueryData = MediaQuery.of(context);
double screenWidth = mediaQueryData.size.width;
double screenHeight = mediaQueryData.size.height;
double angle = animationMenu.value;
returnNew transform. rotate(Angle: Angle, origin: new Offset(24.0, 56.0), alignment: alignment. TopLeft, child: Material( color: Colors.transparent, child: Container( width: screenWidth, height: screenHeight, color: Color(0xFF333333), child: new Stack( children: <Widget>[ _buildMenuTitle(), _buildMenuIcon(), _buildMenuContent(), ], ), ), ), ); }... /// /// Menu Icon /// Widget_buildMenuIcon() {returnNew toy (top: 32.0, left: 4.0, Child: new IconButton(icon: const icon (Icons. Menu, color: Colors.white, ), onPressed: _handleMenuOpenClose, ), ); }... }Copy the code
Now, when we press the menu button, the menu opens, but when we press the button again, the menu does not close. This is what AnimationStatus does.
Let’s add a listener and decide whether to run the animation forward or backward based on AnimationStatus.
///
/// Menu animation status
///
enum _GuillotineAnimationStatus { closed, open, animating }
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
_GuillotineAnimationStatus menuAnimationStatus = _GuillotineAnimationStatus.closed;
_handleMenuOpenClose() {if (menuAnimationStatus == _GuillotineAnimationStatus.closed){
animationControllerMenu.forward().orCancel;
} else if (menuAnimationStatus == _GuillotineAnimationStatus.open) {
animationControllerMenu.reverse().orCancel;
}
}
@override
void initState(){ super.initState(); /// /// Initialization of the animation controller /// animationControllerMenu = new AnimationController( duration: const Duration(milliseconds: 1000), vsync: this ).. addListener((){setState((){}); }).. addStatusListener((AnimationStatus status) {if (status == AnimationStatus.completed) {
///
/// When the animation is at the end, the menu is open
///
menuAnimationStatus = _GuillotineAnimationStatus.open;
} else if (status == AnimationStatus.dismissed) {
///
/// When the animation is at the beginning, the menu is closed
///
menuAnimationStatus = _GuillotineAnimationStatus.closed;
} else{ /// /// Otherwise the animation is running /// menuAnimationStatus = _GuillotineAnimationStatus.animating; }}); . }... }Copy the code
The menu can now open and close as expected, but the previous demo showed us an open/close animation, which is not linear and seems to have a repetitive rebound effect. Next, let’s add this effect.
To do this, I will choose the following two effects:
- Use bounceOut when the menu opens
- BouncIn is used when the menu is closed
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
...
@override
void initState() {... /// /// Initialization of the menu appearance animation // animationMenu = new Tween(begin: -pi / 2.0, end: Animate (New CurvedAnimation(parent: animationControllerMenu, curve: Curves. BounceOut, reverseCurve: Curves.bounceIn, )); }... }Copy the code
There are still some details that are not implemented in this implementation: the title disappears when the menu is opened and appears when the menu is closed. This is a face up/face out effect and is also intended as an animation. Let’s add it.
class _GuillotineMenuState extends State<GuillotineMenu> with SingleTickerProviderStateMixin { AnimationController animationControllerMenu; Animation<double> animationMenu; Animation<double> animationTitleFadeInOut; _GuillotineAnimationStatus menuAnimationStatus; . @override voidinitState(){
...
///
/// Initialization of the menu title fade out/inAnimation // animationTitleFadeInOut = new Tween(begin: 1.0, end: 0.0). Animate (new CurvedAnimation(parent: AnimationControllerMenu, Curve: new Interval(0.0, 0.5, Curve: ease. ease,)); }... /// /// Menu Title /// Widget_buildMenuTitle() {returnNew toy (top: 32.0, left: 40.0, width: screenWidth, height: 24.0, child: new Transform. Rotate (Alignment: Alignment. TopLeft, Origin: Offset. Zero, Angle: PI / 2.0, Child: new Center(Child: new Container(width: double.infinity, height: double.infinity, child: new Opacity( opacity: animationTitleFadeInOut.value, child: new Text('ACTIVITY', textAlign: Textalign.center, style: new TextStyle(color: Colors. White, fontSize: 20.0, fontWeight: FontWeight. Bold, letterSpacing: 2.0,),),),))); }... }Copy the code
The end result is basically as follows:
The full source code for this article is available on GitHub.
conclusion
As you can see, building animations is very simple, even for complex animations.
I hope this longer article succeeds in explaining the animation in Flutter.
Stay tuned for my next article, and in the meantime, happy coding! .