Blog · · 9 min read

BMI Calculator in Flutter – Part 7 – Animated transition

Hello there! I’m back with a new post about implementing Johny Vino‘s BMI Calculator design. If you are not familiar with previous posts, I recommend checking them out. This time we will focus on an animation between the input screen and the result screen. We will change the shape, position and size of a Widget using only one AnimationController. Let’s see how it goes.

The goal

Since we started working on this project, the design changed a lot, therefore the transition animation also changed. However, for the educational purpose, I think it will be much better (and more fun!) if we adapt old animation to the new design. Since the animation is fairly complicated and we already have some advanced stuff here (especially in PacmanSlider), I will be skipping some of the code changes that are only relevant for this project and I will try to focus on general approaches I take to create that animation. If you are interested in the whole code, check out Github repository.

Coming back to work, this is the behavior we would like to achieve:

And this is what we have so far:


Setting up AnimationController

In order to work with animations, we need an AnimationController, here we will use only one named _submitAnimationController. This controller will have 2-second duration and it will get started when the user submits the PacMan slider. So far it makes an effect on the app.

input_page.dart
class InputPageState extends State<InputPage> with TickerProviderStateMixin {
AnimationController _submitAnimationController;
@override
void initState() {
super.initState();
_submitAnimationController = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
);
}
@override
void dispose() {
_submitAnimationController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: PreferredSize(
child: BmiAppBar(),
preferredSize: Size.fromHeight(appBarHeight(context)),
),
body: Column(
children: <Widget>[
...
_buildBottom(context),
],
),
);
}
Widget _buildBottom(BuildContext context) {
return Padding(
padding: EdgeInsets.only(...),
child: PacmanSlider(
onSubmit: onPacmanSubmit,
),
);
}
void onPacmanSubmit() {
_submitAnimationController.forward();
}
...
}

Animating slider’s border

We will start our transition animation with changing slider’s borders to be more rounded. To do that, we will use BorderRadiusTween which will help us animating from one BorderRadius to another. First, let me show the code and then explain it.

pacman_slider.dart
class PacmanSlider extends StatefulWidget {
final VoidCallback onSubmit;
final AnimationController submitAnimationController; //Add AnimationController paramter
...
}
class _PacmanSliderState extends State<PacmanSlider> with TickerProviderStateMixin {
Animation<BorderRadius> _bordersAnimation;
double width = 0.0;
@override
void initState() {
super.initState();
_bordersAnimation = BorderRadiusTween( //create BorderRadiusTween
begin: BorderRadius.circular(8.0),
end: BorderRadius.circular(50.0),
).animate(CurvedAnimation(
parent: widget.submitAnimationController,
curve: Interval(0.0, 0.07), //specify interval from 0 to 7%
));
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
width = constraints.maxWidth;
return AnimatedBuilder(
animation: widget.submitAnimationController,
builder: (context, child) {
Decoration decoration = BoxDecoration(
borderRadius: _bordersAnimation.value, //use the value from the animation
color: Theme.of(context).primaryColor,
);
return Center(
child: Container(
height: screenAwareSize(52.0, context),
decoration: decoration,
child: ...
),
);
},
);
},
);
}
}

The first thing we need to do is pass AnimationController to the PacmanSlider widget, it can be easily done by just adding a new argument to the constructor. Then, we need to create an Animation. As I said earlier, we can use BorderRadiusTween for that. All we need to do is specify starting and ending radius and Flutter will take care of the rest. We also do one more thing with the Animation, we specify Interval curve, which means that our border animation will not be run during whole 2 seconds of AnimationControllerbut only from start to 7% of those 2 seconds. Then we will wrap our widget in AnimatedBuilder which will take care of rebuilding the slider everytime AnimationController changes its value. After that we just need to use the value from_borderAnimation and see the result:


Shrinking the slider down

Great, now let’s try to shrink that slider so that it has the shape of a circle. Let’s start by creating new Animation that will be responsible for animating widget’s width. The difference between this animation and the border one is that we cannot initialize it in initState method, because we want to animate slider’s width obtained from LayoutBuilder during build method. The animation we create will become our source of widget’s width instead of width field.

pacman_slider.dart
class _PacmanSliderState extends State<PacmanSlider> with TickerProviderStateMixin {
Animation<BorderRadius> _bordersAnimation;
Animation<double> _submitWidthAnimation;
double get width => _submitWidthAnimation?.value ?? 0.0; //replace field with a getter
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_submitWidthAnimation = Tween<double>(
begin: constraints.maxWidth, //start with maximum width
end: screenAwareSize(52.0, context), //finish with width equal to height
).animate(CurvedAnimation(
parent: widget.submitAnimationController, //use the same animation controller...
curve: Interval(0.05, 0.15), //... but in different time frame from border animation
));
return AnimatedBuilder(
animation: widget.submitAnimationController,
builder: (context, child) {
...
return Center(
child: Container(
height: screenAwareSize(52.0, context),
width: width, //use new animation width
decoration: decoration,
child: GestureDetector(...),
),
);
},
);
},
);
}
...
}

Theoretically, it already could work, however, inside the GestureDetectorthere is a lot of stuff that doesn’t go well with resizing that widget, so we will do a little hack and replace current content of slider with an empty container if the animation is animating. Thanks to that we won’t have to bother on how Pacman icon or the dots behave when sliders get too narrow. We will use animation’s isDismissed property which tells us if an animation is stopped at the beginning.

pacman_slider.dart
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_submitWidthAnimation = ...;
return AnimatedBuilder(
animation: widget.submitAnimationController,
builder: (context, child) {
Decoration decoration = ...;
return Center(
child: Container(
height: screenAwareSize(52.0, context),
width: width,
decoration: decoration,
child: _submitWidthAnimation.isDismissed //check animation status
? GestureDetector(...)
: Container(),
),
);
},
);
},
);
}

Now we can see our slider shrinking down to the shape of a circle:


Moving the dot

We got the slider animation done, now we can work on the input page. We would like to move the dot we got from the slider to the middle of the screen. To achieve that we will create a Widget that will cover the whole Scaffold after the slider finishes its animation. We will start by wrapping the Scaffold in Stack.

input_page.dart
class InputPageState extends State<InputPage> with TickerProviderStateMixin {
...
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Scaffold(...),
TransitionDot(animation: _submitAnimationController),
],
);
}
}

Notice that we placed TransitionDot after the Scaffold. This way it can cover the default Scaffold. Now let’s take a look at the TransitionDot.

transition_dot.dart
import 'package:bmi_calculator/widget_utils.dart';
import 'package:flutter/material.dart';
class TransitionDot extends AnimatedWidget {
TransitionDot({Key key, Listenable animation})
: super(key: key, listenable: animation);
@override
Widget build(BuildContext context) {
double height = screenAwareSize(52.0, context);
double width = screenAwareSize(52.0, context);
Decoration decoration = BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).primaryColor,
);
Widget dot = Container(
decoration: decoration,
width: width,
height: height,
);
return IgnorePointer(
child: Opacity(
opacity: (super.listenable as Animation).value > 0.15 ? 1.0 : 0.0,
child: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
dot,
],
),
),
),
),
);
}
}

Let’s break it down step by step:

  • We are extending AnimatedWidget. It means that instead of wrapping the whole Widget in AnimatedBuilder, it will be rebuilt every time the animation from constructor changes its value.
  • We create a new dot widget. It will take place of the old slider, the idea is to make it appear in the same position the slider ended up after shrinking down.
  • We wrap the whole widget inside IgnorePointer, so that it can cover the whole screen and not interact with all the controls.
  • We specify the widget’s opacity. If animation is below 0.15 (the point where slider animation ends), opacity is set to 0, so we cannot see the widget, otherwise, we make it visible.
  • Our actual widget is a Scaffold, same as the one from InputPage but without AppBar and with a different body.
  • So far let’s just have a Column with a dot as a body.

The effect looks like this:

Well, not quite impressive, right? Now we can actually animate the position of the dot. Not sure if my idea of how to do it is good, but it works so we will stick with it. So, what we want to do is surround the dot with Spacers inside the Column. Spacer is an empty widget that takes as much space as possible, it also has a flex attribute that can specify the relation of how much space of the whole container multiple spacers should take. The goal is to start with a spacer with high flex on top and very low flex in the bottom. Then, if we change the value of flexes to be equal, the dot will be between two equal spacers so it will be in the middle. To change the flex values we will use IntTween which will increase int values from 0 to 50.

transition_dot.dart
class TransitionDot extends AnimatedWidget {
Animation<int> get positionAnimation => IntTween(
begin: 0,
end: 50,
).animate(
CurvedAnimation(
parent: super.listenable,
curve: Interval(0.15, 0.3),
),
);
@override
Widget build(BuildContext context) {
...
return IgnorePointer(
child: Opacity(
opacity: positionAnimation.value > 0 ? 1.0 : 0.0, //check for positionAnimation
child: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Spacer(flex: 104 - positionAnimation.value), //upper spacer
dot,
Spacer(flex: 4 + positionAnimation.value), //lower spacer
],
),
),
),
),
);
}
}

Those changes cause such result:


Animating dot’s size

Once we get the dot in the middle, we can focus on animating its size. We will create another animation that will be responsible for that, but first, we need to deal with the main problem which is that our dot is supposed to be expanded and shrunk multiple times. Usually, in scenarios like this, we add a listener to the animation and repeat it once it’s done. However, since we are having an Interval animaiton I can’t see how it could work. So how are we going to deal with that? We are going to create our own Animatable.

transition_dot.dart
class LoopedSizeAnimation extends Animatable<double> {
final double defaultSize = 52.0;
final double expansionRange = 30.0;
final int numberOfCycles = 2;
@override
double transform(double t) {
return defaultSize +
math.sin(numberOfCycles * 2 * math.pi * t) * expansionRange;
}
}

As you can see, we used sin function to handle changing the size back and forward. Our animation will go from defaultSize to defaultSize + expansionRange to defaultSize - expansionRange and then come back to deafultSize 2 times. Let’s use it 🙂

transition_dot.dart
class TransitionDot extends AnimatedWidget {
Animation<double> get sizeAnimation => LoopedSizeAnimation().animate(
CurvedAnimation(
parent: super.listenable,
curve: Interval(0.3, 0.8),
),
);
@override
Widget build(BuildContext context) {
double scaledSize = screenAwareSize(sizeAnimation.value, context); //get size from animation
double height = scaledSize; //use new size to get height
double width = scaledSize; //use new size to get width
...
Widget dot = Container(
decoration: decoration,
width: width,
height: height,
);
return ...
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Spacer(flex: 104 - positionAnimation.value),
dot,
Spacer(flex: 4 + positionAnimation.value),
],
),
...
);
}
}

So we use the LoopedSizeAnimation in a combination with Interval. That way our dot will change its size after it got moved to the center. It should look like this:


Expanding the dot

Awesome, now we can modify that animation so that in the end it will cover the whole screen. To do that, we are going to mark a point in time, after which the dot stops pulsing and starts expanding.

transition_dot.dart
class LoopedSizeAnimation extends Animatable<double> {
final double defaultSize = 52.0;
final double expansionRange = 30.0;
final int numberOfCycles = 2;
final double fullExpansionEdge = 0.8;
@override
double transform(double t) {
if (t < fullExpansionEdge) {
double normalizedT = t / fullExpansionEdge;
return defaultSize +
math.sin(numberOfCycles * 2 * math.pi * normalizedT) * expansionRange;
} else {
double normalizedT = (t - fullExpansionEdge) / (1 - fullExpansionEdge);
return defaultSize + normalizedT * 2000.0;
}
}
}

What happens here is that if the time of the animation is below 80%, it will be pulsing. After that, it starts to rapidly grow from default 52 logical pixels to 2000 which should cover the whole screen. Now we need to add small changes in using the animation:

transition_dot.dart
@override
Widget build(BuildContext context) {
double scaledSize = screenAwareSize(sizeAnimation.value, context);
double deviceHeight = MediaQuery.of(context).size.height; //get device height
double deviceWidth = MediaQuery.of(context).size.width; //get device width
double height = math.min(scaledSize, deviceHeight); //make sure the height isn't bigger than the screen
double width = math.min(scaledSize, deviceWidth); //same for width
Decoration decoration = BoxDecoration(
shape: width < 0.9 * deviceWidth ? BoxShape.circle : BoxShape.rectangle, //update shape
color: Theme.of(context).primaryColor,
);
...
}

We changed two things:

  • We make sure that the height and width are smaller than the screen.
  • We make sure that dot changes shape to a rectangle when it gets close to the edge. Otherwise, it wouldn’t be able to cover the whole screen.

Let’s look at the result:


Fade transition

The only thing left is navigating to the new page. We will create a very simple Route so that the transition from the blue screen to the next one will be smooth.

fade_route.dart
class FadeRoute<T> extends MaterialPageRoute<T> {
FadeRoute({WidgetBuilder builder, RouteSettings settings})
: super(builder: builder, settings: settings);
@override
Duration get transitionDuration => const Duration(milliseconds: 500);
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return FadeTransition(opacity: animation, child: child);
}
}

Now let’s use this FadeRoute to navigate to ResultPage. I won’t describe how this page is created since there is nothing interested there. If you are interested, you can check it out here. To navigate to the new page, we will simply add a listener to _submitAnimationController and call Navigator once the controller is completed.

input_page.dart
class InputPageState extends State<InputPage> with TickerProviderStateMixin {
AnimationController _submitAnimationController;
@override
void initState() {
super.initState();
_submitAnimationController = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
);
_submitAnimationController.addStatusListener((status) { //add a listener
if (status == AnimationStatus.completed) {
_goToResultPage().then((_) => _submitAnimationController.reset()); //reset controller when coming back
}
});
}
_goToResultPage() async {
return Navigator.of(context).push(FadeRoute( //use the FadeRoute
builder: (context) => ResultPage(
weight: weight,
height: height,
gender: gender,
),
));
}
...
}

Alright, let’s see the final result!

And that’s it! I hope you liked this post. 🙂

I expect it to be the last post of the BMI Calculator since the design part is mostly done. I really enjoyed doing this app and sharing the process with you. I hoped you enjoyed it too.

If you liked the whole series, leave a star on the Github repository where you can find all the code, I would appreciate it. 🙂

Cheers 🙂

🔥 BMI Calculator 🔥

  1. Gender input
  2. Weight input
  3. Height input
  4. Static layouts
  5. Animated slider hint
  6. Custom slider
  7. Animated transition