Animation video in Flutter

How I created a YouTube video using Flutter animations

Danielle H
8 min readOct 27, 2023

At the moment, my family is all suffering from some form of anxiety, including my kids (among other things). I searched for a fun animation of the candle and flower breathing exercise that can be followed easily, and for some reason I didn’t find anything that my 11-year-old liked. The music was annoying, or there was talking before and after, or there was a creepy Darth Vader breathing sound.

Not really soothing as such.

So I decided to create my own, with what I know. And at the moment, I know Flutter.

The final video looks like this:

The plan

So, this is what I need:

  • A nice gradient full screen background
  • A flower that expands for 4 seconds
  • A candle that shrinks for 6 seconds
  • Some text explaining what to do when
  • Countdown text
  • Progress bar
  • Soothing background music.

Assets

A great site for free assets is Pixabay. Both the flower and the music are from there.

The candle caused some problems. Had I just wanted a candle on a transparent background, that also exists in Pixabay. However, I wanted the candle to flicker. So I needed a gif of a candle on a transparent background.

I couldn’t find one.

In the end I created it myself (I will explain how in another article :)) and then uploaded it to Pixabay, you can find it here.

OK, we’ve got our assets. Let’s add them to pubspec.yaml:

# To add assets to your application, add an assets section, like this:
assets:
- lib/images/ #if it's inside the lib folder
- images #if it's in your root folder

Framework

As I plan to create more breathing animations, I created AnimationParameters:

class AnimationParameters {
//inhale time
final Duration inhale;
//hold breath after inhale time
final Duration holdInhale;
//exhale time
final Duration exhale;
//hold breath after exhale
final Duration holdExhale;
//number of inhale and axhale cycles
final int cycles;

//use doubles in case I want fractions later
AnimationParameters(
{double inhale = 4,
double holdInhale = 0.5,//can't do it 0, it transitions too fast
double exhale = 6,
double holdExhale = 0.5,//same
double intro = 3,
this.cycles = 6})
: inhale = Duration(milliseconds: (inhale * 1000).floor()),
holdInhale = Duration(milliseconds: (holdInhale * 1000).floor()),
exhale = Duration(milliseconds: (exhale * 1000).floor()),
holdExhale = Duration(milliseconds: (holdExhale * 1000).floor());
}

And ShowAnimationPage that receives AnimationParameters:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../model/animation_parameters.dart';

class ShowAnimationPage extends StatefulWidget {
const ShowAnimationPage({super.key, required this.parameters});

final AnimationParameters parameters;
@override
State<ShowAnimationPage> createState() => _ShowAnimationPageState();
}

class _ShowAnimationPageState extends State<ShowAnimationPage>{

//we'll add animations here soon

//this should be in theme
final Color darkPurple = const Color(0xff4b00de);
final Color lightPurple = const Color(0xffae2dc4);


@override
void initState() {
//and we'll listen to the animations here

//Remove the status bar
SystemChrome.setEnabledSystemUIMode(SystemUiMode.leanBack);
super.initState();
}

@override
void dispose() {
//we'll dispose of the animations here
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
//this is our linear gradient background
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [darkPurple, lightPurple])),
child: Stack(alignment: Alignment.center, children: [
//we'll put children here soon
])),
);
}
}

We’ve got our nice background:

Soothing. Let’s stop here.

Note the line:

SystemChrome.setEnabledSystemUIMode(SystemUiMode.leanBack);

This removes the status bar and shows our animation in full-screen mode.

Animations

I planned to use AnimatedPositioned for the flower and candle, like this:

double imageWidth = 150;
...
Wdiget build ( BuildContext context) {
...
Stack(alignment: Alignment.center, children: [
AnimatedPositioned(
duration: currentDuration,
width: imageWidth,
child: Image.asset(
"lib/images/flower.png",
),
),
])
}

but this had some problems.

  • Something needs to trigger the animation, and trigger the change between flower and candle. A timer? An AnimationController?
  • Even when using a timer, the first time, the flower wouldn’t expand.

AnimatedWidget widgets are automatic; WidgetTransition widgets need an AnimationController and can be controlled programmatically.

While it is possible to trigger an AnimatedPositioned on startup, it isn’t really the recommended way to do so. The recommended way to trigger animations and control them is using an AnimationController.

So instead of using AnimatedPositioned, I used ScaleTransition. In general in Flutter, AnimatedWidget widgets are automatic; WidgetTransition widgets need an AnimationController and can be controlled programmatically. A great tutorial on this is here.

ScaleTransition

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../model/animation_parameters.dart';

class ShowAnimationPage2 extends StatefulWidget {
const ShowAnimationPage2({super.key, required this.parameters});

final AnimationParameters parameters;
@override
State<ShowAnimationPage2> createState() => _ShowAnimationPage2State();
}

class _ShowAnimationPage2State extends State<ShowAnimationPage2>
with TickerProviderStateMixin {

//animation controller
late final AnimationController _controller = AnimationController(
duration: widget.parameters.inhale,
vsync: this,
)..forward();

//scale animation
late final Animation<double> _animation =
Tween<double>(begin: 0.6, end: 1).animate(_controller);


//this should be in theme
final Color darkPurple = const Color(0xff4b00de);
final Color lightPurple = const Color(0xffae2dc4);

//current animation duration
late Duration currentDuration = widget.parameters.inhale;
//are we during forward of animation or reverse?
bool forward = true;

@override
void initState() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.leanBack);
//trigger action on animation states
_controller.addStatusListener((status) {
//finished forward
if (status == AnimationStatus.completed) {
if (forward) {
Future.delayed(widget.parameters.holdInhale, () {
//update duration and trigger reverse
_controller.duration = widget.parameters.exhale;
_controller.reverse();
currentDuration = widget.parameters.exhale;
setState(() {
forward = false;
});
});
}
}
//finished reverse
else if (status == AnimationStatus.dismissed) {
Future.delayed(widget.parameters.holdExhale, () {
//update duration and trigger forward
_controller.duration = widget.parameters.inhale;
currentDuration = widget.parameters.inhale;
_controller.forward();
setState(() {
forward = true;
});
});
}
});
super.initState();
}

@override
void dispose() {
//dispose of the animations
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
//this is our linear gradient background
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [darkPurple, lightPurple])),
child: Stack(alignment: Alignment.center, children: [
Center(
child: SizedBox(
width: 250, //full size, scale = 1
child: ScaleTransition(
scale: _animation,
child: forward
? Image.asset(
"lib/images/flower.png",
)
: Image.asset(
"lib/images/candle-transparent.gif",
),
),
),
),
])),
);
}
}

What did we do?

  • We created a controller, with the duration of the inhale.
  • We created an Animation. As we want the image to animate between a width of 150 and a width of 250, which translates to a scale between 0.6*250 to 1*250, we create an animation between 0.6 to 1. We could also have defined scale=1 for width=150 and changed the numbers accordingly.
  • We defined a boolean forward to keep track of what image we want to show.
  • We listen to the animation status. When we finish forward() we update duration and forward boolean and trigger reverse(). When we finish the reverse() animation, we update duration and forward and trigger forward().
  • Finally, we create a ScaleTransition widget and show flower or candle depending on forward.

This is the result:

Excellent.

Smooth transition

The transition between the flower and the candle is a little abrupt. Let’s smooth it with the AnimatedCrossFade widget:

ScaleTransition(
scale: _animation,
child: AnimatedCrossFade(
duration: widget.parameters.holdExhale,
crossFadeState: forward
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: Image.asset(
"lib/images/flower.png",
),
secondChild: Image.asset(
"lib/images/candle-transparent.gif",
),
),
),

And the result:

Sweet!

Text

The text needs a timer, to count out the seconds. Then we update the text with the seconds left.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import '../model/animation_parameters.dart';

class ShowAnimationPage2 extends StatefulWidget {
const ShowAnimationPage2({super.key, required this.parameters});

final AnimationParameters parameters;
@override
State<ShowAnimationPage2> createState() => _ShowAnimationPage2State();
}

class _ShowAnimationPage2State extends State<ShowAnimationPage2>
with TickerProviderStateMixin {

late final AnimationController _controller = AnimationController(
duration: widget.parameters.inhale,
vsync: this,
)..forward();

//scale animation
late final Animation<double> _animation =
Tween<double>(begin: 0.6, end: 1).animate(_controller);
bool forward = true;

//this should be in theme
final Color darkPurple = const Color(0xff4b00de);
final Color lightPurple = const Color(0xffae2dc4);
late final Color textPurple = Color.lerp(lightPurple, Colors.white, 0.5)!;

//current animation duration
late Duration currentDuration = widget.parameters.inhale;

//timer
late int timerValue = widget.parameters.inhale.inSeconds;
late Timer _timer;
String message = "";

//on timer tick, update timerValue.
//Cancel timer if done, new timer in animation listener
void timerCallback(Timer timer) {
setState(() {
timerValue = timerValue - 1;
if (timerValue == 0) {
timer.cancel();
}
});
}

@override
void initState() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.leanBack);

//create timer
_timer = Timer.periodic(Duration(seconds: 1), timerCallback);

_controller.addStatusListener((status) {
print("here $forward status: $status");
if (status == AnimationStatus.completed) {
if (forward) {
Future.delayed(widget.parameters.holdInhale, () {
//update duration and trigger reverse
_controller.duration = widget.parameters.exhale;
_controller.reverse();
currentDuration = widget.parameters.exhale;
//update timerValue
timerValue = widget.parameters.exhale.inSeconds;
//restart timer
_timer = Timer.periodic(Duration(seconds: 1), timerCallback);
setState(() {
forward = false;
});
});
}
} else if (status == AnimationStatus.dismissed) {
Future.delayed(widget.parameters.holdExhale, () {
//update duration and trigger forward
_controller.duration = widget.parameters.inhale;
currentDuration = widget.parameters.inhale;
_controller.forward();
//update timerValue
timerValue = widget.parameters.inhale.inSeconds;
//restart timer
_timer = Timer.periodic(Duration(seconds: 1), timerCallback);
setState(() {
forward = true;
});
});
}
});
super.initState();
}

@override
void dispose() {
//dispose of the animations
_controller.dispose();
//cancel timer
_timer.cancel();
super.dispose();
}

@override
Widget build(BuildContext context) {
//text message
message = forward ? "Smell the flower" : "Blow out the candle";
return Scaffold(
body: Container(
//this is our linear gradient background
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [darkPurple, lightPurple])),
child:
...
//text and timer value
Positioned(
bottom: 10,
child: Text(
timerValue == 0 ? "" : "$message $timerValue",
style: Theme.of(context)
.textTheme
.displayMedium!
.copyWith(color: textPurple),
))
])),
);
}
}

Let’s look at the code:

  • We create a timer _timer, an int timerValue that will hold the number of seconds left, and a String message for the text message.
  • We create a timer callback function that is called every second called timerCallback. It updates the timerValue int value, and cancels the timer when it reaches 0.
  • In the animation listener, we also create a new timer and update timerValue.
  • We define the message depending on forward.
  • Finally, we show the results of all this hard work in a Positioned widget below the animation.

Progress Bar

Last but not least, we need to show how many breaths are left in the video. For this, we need to keep track of the number of breaths taken and the total number of breaths in the animation.

//progress
int cycle = 0;

...

//in initState
else if (status == AnimationStatus.dismissed) {
Future.delayed(widget.parameters.holdExhale, () {
//update duration and trigger forward
_controller.duration = widget.parameters.inhale;
currentDuration = widget.parameters.inhale;
_controller.forward();
timerValue = widget.parameters.inhale.inSeconds;
_timer = Timer.periodic(Duration(seconds: 1), timerCallback);
setState(() {
forward = true;
cycle = cycle + 1;//udpate cycle
});
});

...

//in build, in the stack
Positioned(//this is the text from before
bottom: 10,
child: Text(
timerValue == 0 ? "" : "$message $timerValue",
style: Theme.of(context)
.textTheme
.displayMedium!
.copyWith(color: textPurple),
)),
Align(//this is the progress bar
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(
color: lightPurple,
backgroundColor: darkPurple,
value: cycle.toDouble() / widget.parameters.cycles),
)


See the progressbar at the bottom?

Done!

Exporting as video

I simply recorded from the emulation tools. Click on the 3 dots …

And then choose Record and Playback (the gifs in this article were created the same way):

You can save as .gif or as .webm.

Another option is the render plugin.

I added title cards and the background music in ClipChamp, but I am adding it to the app as well.

Another great thing about doing animations in Flutter, is that it’s trivial to change the language. You can see the Hebrew version of the video on YouTube here.

What do you use to relax? How do you use Flutter animations? Let me know in the comments.

--

--

Danielle H

I started programming in LabVIEW and Matlab, and quickly expanded to include Android, Swift, Flutter, Web(PHP, HTML, Javascript), Arduino and Processing.