Flutter: Animated Progress/Count Bar

Soo Kim
7 min readJan 21, 2023

--

I made a very simple progress/count bar app that I thought would be useful in sharing my programming stylistic choices, how I use Provider and animation in Flutter. Click here for the full code.

Side-note: I’ve only used Provider for state management so I am limited in my knowledge of other state management packages, but one fact I know for certain is do not use GetX. I even quit my previous job because the new team manager wanted to re-do the whole app using GetX and I disagreed firmly trying it out due to various reasons that I won’t get into. This happened 2 months after my first job ever as a developer…

As you read through my code, you might notice some stylistic differences in my code with which some people might disagree. I do think, however, that although Dart is a statically typed language, do always declare your variable with a specific type. It reads better, prevents runtime errors, and actually allocates a (smaller and) proper amount of space for the variable.

main.dart

When I create instances of my provider, rarely do I actually listen to it (or use context.watch()). Usually, I will create an instance using listen: false in order to use its methods, and in each widget that requires a changing value from the provider, use the Builder widget + context.select().

import 'package:flutter/material.dart';
import 'package:progress_bar/progress_bar.dart';
import 'package:provider/provider.dart';

import 'animated_bar.dart';
import 'count_provider.dart';

void main() => runApp(const ProgressBarApp());

class ProgressBarApp extends StatelessWidget {
const ProgressBarApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
/// in an actual app, you'll be using MultiProvider
return ChangeNotifierProvider<CountProvider>(
create: (_) => CountProvider(),
child: const MaterialApp(home: Home()),
);
}
}

class Home extends StatelessWidget {
const Home({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
/// listen: false if you do not want this widget to rebuild when the provider notifies its listeners
/// since I'm only creating the instance to use its methods, do not need to listen
final CountProvider _countProvider = Provider.of(context, listen: false);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Builder(
/// use Builder + select so that only this widget will rebuild when the selected value changes in a provider
builder: (BuildContext ctx) {
final int _count = ctx.select<CountProvider, int>((CountProvider p) => p.count1);
return ProgressBar(count: _count);
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 30.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: const Icon(Icons.remove),
onPressed: _countProvider.removeCount1,
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _countProvider.addCount1,
),
],
),
),
const AnimatedBar(),
],
),
),
);
}
}

Some might ask, why not use context.select() in ProgressBar itself and not use Builder. I’d say that’s fine if you’re using a widget that will not be re-used anywhere else. I use the ProgressBar widget in two different places — and hence, use Builder to pass on the count value.

ProgressBar

Straight-forward UI so no explanation for the progress bar.

import 'package:flutter/material.dart';

class ProgressBar extends StatelessWidget {
const ProgressBar({Key? key, required this.count}) : super(key: key);
final num count;

@override
Widget build(BuildContext context) {
final double _barWidth = MediaQuery.of(context).size.width * 0.7;

return Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Stack(
children: <Widget>[
Container(
margin: const EdgeInsets.only(right: 20.0),
height: 20.0,
width: _barWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15.0),
color: const Color.fromRGBO(234, 234, 234, 1.0)
),
),
Container(
height: 20.0,
width: _barWidth * count/7,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15.0),
gradient: const LinearGradient(
colors: <Color>[
Color.fromRGBO(185, 235, 255, 1.0),
Color.fromRGBO(145, 224, 255, 1.0),
Color.fromRGBO(85, 206, 255, 1.0),
Colors.lightBlueAccent,
],
stops: [0.25, 0.5, 0.75, 1.0],
)
),
),
],
),
RichText(
text: TextSpan(
style: const TextStyle(color: Colors.black, fontSize: 15.0, fontWeight: FontWeight.w600),
children: <InlineSpan>[
TextSpan(
text: count.roundToDouble().toString().substring(0, 1),
style: const TextStyle(color: Colors.lightBlueAccent, fontSize: 24.0),
),
const TextSpan(text: " / 7")
],
),
),
],
);
}
}

Count Provider

The only thing to explain here is that for the bottom bar (animated), when the count reaches 7, a pop up dialog opens and when it is closed, the progress bar goes back to 0 and a coupon is “issued” (which starts as a small icon and becomes bigger). These steps are done using resetCount() and changeCount().

import 'package:flutter/foundation.dart';

class CountProvider extends ChangeNotifier {
/// for static progress bar
int _count1 = 0;
int get count1 => this._count1;
set count1(int i) => throw "error";

/// for animated progress bar
double _count2 = 6.0;
double get count2 => this._count2;
set count2(double i) => throw "error";

int _couponCount = 2;
int get couponCount => this._couponCount;
set couponCount(int i) => throw "error";

bool _needNewCoupon = false;
bool get needNewCoupon => this._needNewCoupon;
set needNewCoupon(bool b) => throw "error";

void addCount1(){
if (this._count1 == 7) return;
this._count1++;
this.notifyListeners();
}

void removeCount1(){
if (this._count1 == 0) return;
this._count1--;
this.notifyListeners();
}

/// called when progress bar is animating & completed
void changeCount(double count){
if (count == 0 && this._needNewCoupon) {
this._couponCount ++;
this._needNewCoupon = false;
this.notifyListeners();
}
this._count2 = count;
this.notifyListeners();
}

void resetCount(){
this._needNewCoupon = true;
this.notifyListeners();
}
}

Animated Progress Bar — UI and Setting the Animation Up

For the animated one, you need a stateful widget with TickerProviderStateMixin. I’ll go over the view first.

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
children: <Widget>[
Builder(
builder: (BuildContext ctx) {
final int _stampCount = ctx.select<CountProvider, int>((CountProvider p) => p.couponCount);
if (_stampCount == 0) return const SizedBox();
return Container(
margin: const EdgeInsets.only(left: 30.0),
child: Row(
children: List.generate(_stampCount, (int i) => SizedBox(
width: 30.0,
child: Icon(Icons.local_attraction, size: 26.0),),
),
),
);
},
),
Builder(
builder: (BuildContext ctx) {
final bool _needNewStamp = ctx.select<CountProvider, bool>((CountProvider p) => p.needNewCoupon);
if (!_needNewStamp) return const SizedBox();
return Container(
width: 30.0,
child: Icon(Icons.local_attraction, size: this._stampAnimation?.value ?? 26.0),
);
},
),
],
),
Builder(
builder: (BuildContext ctx) {
final double _count = ctx.select<CountProvider, double>((CountProvider p) => p.count2);
return ClipRRect(
borderRadius: BorderRadius.circular(15.0),
child: ProgressBar(count: _count),
);
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 30.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: const Icon(Icons.remove),
onPressed: () async {
final int _count = this._countAnimation!.value.toInt();
if (_count == 0) return;
this._countAnimation = Tween<double>(begin: _count.toDouble(), end: _count - 1).animate(this._countAnimationController!);
this._countAnimationController?.reset();
await this._countAnimationController!.forward();
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
final int _count = this._countAnimation!.value.toInt();
this._countAnimation = Tween<double>(begin: _count.toDouble(), end: _count + 1).animate(this._countAnimationController!);
this._countAnimationController?.reset();
await this._countAnimationController!.forward();
},
),
],
),
),
],
);
}

As you can see here, I’ve used Builder for 3 different components — count, coupons, and needNewCoupon, which is used for the newly issued coupon that animates from small → big. This way, only the necessary parts are rebuilt when the progress bar is animating. The _count variable is changed the whole duration of the animation, but the number of coupons and needNewCoupon will not change until the count reaches 7.

Now, for the animation. Let’s look just at the a snippet of initState and the + method to see how to begin the progress bar animation.

Animation<double>? _countAnimation;
AnimationController? _countAnimationController;

@override
void initState() {
super.initState();
this._countAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() async {
...(bunch of code)...
});
final double _count = context.read<CountProvider>().count2;
this._countAnimation = Tween<double>(begin: _count, end: _count+1).animate(this._countAnimationController!);
}

/// + button onPressed
() async {
final int _count = this._countAnimation!.value.toInt();
this._countAnimation = Tween<double>(begin: _count.toDouble(), end: _count + 1).animate(this._countAnimationController!);
this._countAnimationController?.reset();
await this._countAnimationController!.forward();
},

There are two classes you need for animation (my animation way anyways…there are other ways): Animation and Animation Controller. You have to instantiate the controller in initState because it needs the “Ticker Provider” (and therefore, “this” — the stateful widget — because the TickerProviderStateMixin is used). You also have to instantiate the animation in the initState because it needs the controller (in .animate( )).

So in initState, I instantiate the animation controller and then add a listener so that I can change my variables in count provider. Then, I assign it to the Tween animation. I need to assign the animation here first so that I can use its value when the + is pressed. Be careful with the null safety here…make sure that all variables are assigned before you use them!

When you first press the + button, it gets the animation value (which will be the count) and assign a new animation. If you .forward() the animation controller, the animation begins! (There’s reverse, reset, stop etc). I have .reset() because I need a new animation to begin (go forward) each time I press the button but “reverse” will just reverse the previous animation.

Animated Progress Bar — the Animation Itself

Let’s look at the my full code for initState that includes the count animation and coupon animation.

@override
void initState() {
super.initState();
this._countAnimationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() async {

if (this._countAnimationController!.isAnimating) {
context.read<CountProvider>().changeCount(this._countAnimation!.value);
}

if (this._countAnimationController!.isCompleted) {
context.read<CountProvider>().changeCount(this._countAnimation!.value);
if (context.read<CountProvider>().count2 == 7) {
await showDialog(
context: context,
builder: (BuildContext ctx) {
return Dialog(
child: Container(
alignment: Alignment.center,
height: 150.0,
width: 100.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text("Congratulations!\nYou have received a coupon!", style: TextStyle(fontSize: 16.0),),
TextButton(onPressed: Navigator.of(ctx).pop, child: Text("Close", style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600),))
],
)
),
);
},
);

this._countAnimation = Tween<double>(begin: 7.0, end: 0.0).animate(this._countAnimationController!);
this._countAnimationController?.reset();
context.read<CountProvider>().resetCount();
this._countAnimationController!.forward();
this._couponAnimationController!.forward();
}
}
});

this._couponAnimationController = AnimationController(vsync: this, duration: Duration(milliseconds: 600))
..addListener(() {
if (this._couponAnimationController!.isAnimating) this.setState(() {});
});
final double _count = context.read<CountProvider>().count2;
this._couponAnimation = Tween<double>(begin: 15.0, end: 26.0).animate(this._couponAnimationController!);
this._countAnimation = Tween<double>(begin: _count, end: _count+1).animate(this._countAnimationController!);
}

When the animation is in progress, you want to notify the count variable so that it updates the progress bar. If you don’t use changeNotifier, you’d have to use setState to update the UI (as I did in the couponAnimationController). However, that means I’ll be updating the whole widget, which includes the coupons and the buttons. With the builder + this method, I can update only the progress bar.

You also need to notify the count when the animation is complete (when the animation reaches its end value). If not, the animation will stop short. For example, if the progress bar is going from 3 to 4, it’ll stop at 3.92. When the animation is complete and the count is 7, it shows a pop up dialog, after which the progress bar goes from 7 → 0, and the coupon animation begins.

Happy coding!

--

--