Ahsin Irshad
Senior Flutter Developer | Ex. Native Android Developer
Learn Flutter Animation by Doing — Build Complex Animations the Simple Way
You might already be familiar with creating basic animations in Flutter. But how about transforming these simple widgets into stunning animations? Let’s dive in and discover how to make it happen, starting with the impressive animation you can see below 👇
Before we start, thanks to Louis for this fantastic animation. Also, for those who prefer video guides, a tutorial is for you!
So, let’s start
You can create a new Flutter project or use an existing one. The black hole in the animation is actually an image, so let’s add it to the project. Next, let’s create our animation page, I’m gonna call it CardHiddenAnimationPage
class CardHiddenAnimationPage extends StatefulWidget {
const CardHiddenAnimationPage({Key? key}) : super(key: key);
@override
State<CardHiddenAnimationPage> createState() =>
CardHiddenAnimationPageState();
}
class CardHiddenAnimationPageState extends State<CardHiddenAnimationPage>
with TickerProviderStateMixin {
final cardSize = 150.0;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: Row(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.remove),
),
const SizedBox(width: 20),
FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
],
),
// TODO: add body
);
}
}
It’s a StatefulWidget
with TickerProviderStateMixin
where we have two floating buttons on the bottom right side and define the cardSize = 150
Black hole animation
The plan is simple when the minus button is clicked, the black hole appears then after a moment it disappears. In scenarios like this, the Tween
widget is extremely useful. Let me show you how
late final holeSizeTween = Tween<double>(
begin: 0,
end: 1.5 * cardSize,
);
late final holeAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
double get holeSize => holeSizeTween.evaluate(holeAnimationController);
Next, we need to ensure that animation changes are listened, and it’s crucial to dispose of it appropriately when it’s no longer needed.
@override
void initState() {
holeAnimationController.addListener(() => setState(() {}));
super.initState();
}
@override
void dispose() {
holeAnimationController.dispose();
super.dispose();
}
We are done with the animation setup. Now, let’s put it on the page. Just change TODO: add body
to the below code👇
body: Center(
child: SizedBox(
height: cardSize * 1.25,
width: double.infinity,
// TODO: wrap Stack with ClipPath
child: Stack(
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
children: [
SizedBox(
width: holeSize, // animate the black hole
child: Image.asset(
'images/hole.png',
fit: BoxFit.fill,
),
),
// TODO: Hello world card
],
),
),
),
It’s show time! Let’s return to the minus floating action button and make its onPressed
function asynchronous.
FloatingActionButton(
onPressed: () async {
await holeAnimationController.forward();
Future.delayed(const Duration(milliseconds: 200),
() => holeAnimationController.reverse());
},
....
)
What we’re aiming to do here is let the animation finish first, wait 200 milliseconds, and then reverse the animation.
Card animation
First, let’s make a card. I’m going to call it HelloWorldCard
class HelloWorldCard extends StatelessWidget {
const HelloWorldCard({
Key? key,
required this.size,
required this.elevation,
}) : super(key: key);
final double size;
final double elevation;
@override
Widget build(BuildContext context) {
return Material(
elevation: elevation,
borderRadius: BorderRadius.circular(10),
child: SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Colors.blue,
),
child: const Center(
child: Text(
‘Hello\nWorld’,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
),
);
}
}
Now, change TODO: Hello world card
to the code given below
Positioned(
child: Center(
child: Transform.translate(
offset: Offset(0, 0), // TODO: Animate the offset
child: Transform.rotate(
angle: 0, // TODO: Animate the angle
child: Padding(
padding: const EdgeInsets.all(20.0),
child: HelloWorldCard(
size: cardSize,
elevation: 2, // TODO: Animate the elevation
),
),
),
),
),
),
We’re almost there. We’ve added the black hole and the hello world card. The plan is, when the user clicks the minus button, the card will move downward, rotate slightly, and its elevation will increase. To do that, we’ll use the same Tween technique that we used to animate the hole size.
late final cardOffsetAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
late final cardOffsetTween = Tween<double>(
begin: 0,
end: 2 * cardSize,
).chain(CurveTween(curve: Curves.easeInBack));
late final cardRotationTween = Tween<double>(
begin: 0,
end: 0.5,
).chain(CurveTween(curve: Curves.easeInBack));
late final cardElevationTween = Tween<double>(
begin: 2,
end: 20,
);
double get cardOffset =>
cardOffsetTween.evaluate(cardOffsetAnimationController);
double get cardRotation =>
cardRotationTween.evaluate(cardOffsetAnimationController);
double get cardElevation =>
cardElevationTween.evaluate(cardOffsetAnimationController);
In the initState
, add the listener for observing changes
cardOffsetAnimationController.addListener(() => setState(() {}));
Don’t forget to dispose of it when it’s no longer needed
cardOffsetAnimationController.dispose();
Let’s back to our HelloWorldCard
and update the values for offset
, angle
, and elevation
. Once these changes are completed, it should look like this 👇
Positioned(
child: Center(
child: Transform.translate(
offset: Offset(0, cardOffset), // Offset updated
child: Transform.rotate(
angle: cardRotation, // angle updated
child: Padding(
padding: const EdgeInsets.all(20.0),
child: HelloWorldCard(
size: cardSize,
elevation: cardElevation, // elavetion updated
),
),
),
),
),
),
Returning to the minus button, also start the card animation upon its click
holeAnimationController.forward();
await cardOffsetAnimationController.forward();
Future.delayed(const Duration(milliseconds: 200),
() => holeAnimationController.reverse());
The duration of the cardOffsetAnimationController
is set to 1000 milliseconds. After the hole animation finishes, I want it to wait until the card animation is complete before reversing it. That’s why put await
before the cardOffsetAnimationController
Last but not least, on the plus button, all we need to do is reverse the animations.
cardOffsetAnimationController.reverse();
holeAnimationController.reverse();
The wall — CustomClipper
The black hole is displayed and the card animates as expected, yet it doesn’t quite create the effect we’re aiming for. We want to give the impression that the card is moving towards the black hole, and then returning from it. We can get this done with ClipPath
and CustomClipper
. First, let’s create the CustomClipper, I’m gonna call it BlackHoleClipper
.
class BlackHoleClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
path.moveTo(0, size.height / 2);
path.arcTo(
Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2),
width: size.width,
height: size.height,
),
0,
pi,
true,
);
// Using -1000 guarantees the card won't be clipped at the top, regardless of its height
path.lineTo(0, -1000);
path.lineTo(size.width, -1000);
path.close();
return path;
}
@override
bool shouldReclip(BlackHoleClipper oldClipper) => false;
}
At TODO: wrap Stack with ClipPath
, wrap the Stack
widget with ClipPath
and assign BlackHoleClipper
as the clipper
.
....
ClipPath(
clipper: BlackHoleClipper(),
child: Stack( .... ),
)
....
We’ve done it! 🎉 Now it appears precisely as we intended. To my eyes, it looks smooth and satisfying. Here’s the complete source code for reference, or if you encountered any difficulties following my instructions.
If I got something wrong? Let me know in the comments. I would love to improve.
Clap 👏 If this article helps you.
#Learn #Flutter #Animation #Build #Complex #Animations #Simple #Flutter