“Flutter makes it easy and fast to build beautiful mobile apps” . So let’s put this to the test by creating a custom loading animation 🙂

Of course, Flutter provides a widget for Material Design progress indicator, but let’s create something a bit more custom. How about 4 circles of different colours, growing in size and rotating?

For this code tutorial, we’ll create an app with one screen. When it starts, it simulates loading data, and while it does, a custom animation is shown. When the data is loaded, the animation stops.

Setting up the app

To follow the code tutorial, create a new app as follows.

Create app flutter create loadinganimationexample 1 flutter create loadinganimationexample

If you’re unsure how to set up a Flutter app, check out Getting started with Flutter official tutorial.

Firstly, we create a Material app in main.dart, which will launch the HomePage widget.

main.dart import 'package:flutter/material.dart'; import 'home_page.dart'; void main() { runApp(new MyApp()); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return new MaterialApp( title: 'Custom Loading Animation Example', theme: new ThemeData( primaryColor: const Color(0xFF43a047), accentColor: const Color(0xFFffcc00), primaryColorBrightness: Brightness.dark, ), home: new HomePage(), ); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import 'package:flutter/material.dart' ; import 'home_page.dart' ; void main ( ) { runApp ( new MyApp ( ) ) ; } class MyApp extends StatelessWidget { // This widget is the root of your application. @ override Widget build ( BuildContext context ) { return new MaterialApp ( title : 'Custom Loading Animation Example' , theme : new ThemeData ( primaryColor : const Color ( 0xFF43a047 ) , accentColor : const Color ( 0xFFffcc00 ) , primaryColorBrightness : Brightness . dark , ) , home : new HomePage ( ) , ) ; } }

Secondly, we create home_page.dart. This displays a Text saying the data is loaded, or a loading animation, depending on its state. For now, the loading animation is a CircularProgressIndicator: this will be replaced with the custom animation in the next section.

home_page.dart import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { HomePage({Key key}) : super(key: key); @override _HomePageState createState() => new _HomePageState(); } class _HomePageState extends State<HomePage> { bool _loadingInProgress; @override void initState() { super.initState(); _loadingInProgress = true; } @override Widget build(BuildContext context) { return new Scaffold( appBar: new AppBar( title: new Text("Custom Loading Animation example"), ), body: _buildBody(), ); } Widget _buildBody() { if (_loadingInProgress) { return new Center( child: new CircularProgressIndicator(), ); } else { return new Center ( child: new Text('Data loaded'), ); } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import 'package:flutter/material.dart' ; class HomePage extends StatefulWidget { HomePage ( { Key key } ) : super ( key : key ) ; @ override _HomePageState createState ( ) = > new _HomePageState ( ) ; } class _HomePageState extends State < HomePage > { bool _loadingInProgress ; @ override void initState ( ) { super . initState ( ) ; _loadingInProgress = true ; } @ override Widget build ( BuildContext context ) { return new Scaffold ( appBar : new AppBar ( title : new Text ( "Custom Loading Animation example" ) , ) , body : _buildBody ( ) , ) ; } Widget _buildBody ( ) { if ( _loadingInProgress ) { return new Center ( child : new CircularProgressIndicator ( ) , ) ; } else { return new Center ( child : new Text ( 'Data loaded' ) , ) ; } } }

Lastly, let’s simulate loading the data. This is an async operation so we’ll use a Future. As it’s a simulation, we’ll simply use a Delayed future of 5 seconds.

home_page.dart [...] import 'dart:async'; [...] class _HomePageState extends State<HomePage> { bool _loadingInProgress; @override void initState() { super.initState(); _loadingInProgress = true; _loadData(); } [...] Future _loadData() async { await new Future.delayed(new Duration(seconds: 5)); _dataLoaded(); } void _dataLoaded() { setState(() { _loadingInProgress = false; }); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 [ . . . ] import 'dart:async' ; [ . . . ] class _HomePageState extends State < HomePage > { bool _loadingInProgress ; @ override void initState ( ) { super . initState ( ) ; _loadingInProgress = true ; _loadData ( ) ; } [ . . . ] Future _loadData ( ) async { await new Future . delayed ( new Duration ( seconds : 5 ) ) ; _dataLoaded ( ) ; } void _dataLoaded ( ) { setState ( ( ) { _loadingInProgress = false ; } ) ; } }

Looking for a Flutter job? Check out my job board dedicated to Flutter at flutterjobs.info

Creating the animation

The animation is centred on the screen. It consists of 4 circles of different colours. They start small then grow in size. As they grow, they also rotate. Once the animation is completed, it runs in reverse, then in forward again and so on, until the animation is stopped.

Animations are orchestrated with SingleTickerProviderStateMixin and are made up of 2 objects: 1 AnimationController and at least 1 Animation value. The controller allows you to start the animation, forward or in reverse. It also has a duration for the animation. The Animation object holds the current value, which changes over time between 0.0 and 1.0 . This is this value that you use when creating your layout. It can be a double, but other types too, such as Color. It also has a listener for when the animation has reached the end. The way this value changes can be linear, curved etc. This value may change from x to y, instead of 0.0 to 1.0, by using a Tween.

Sounds confusing? Let’s amend home_page.dart with a 2 seconds animation. We will use 2 Animation objects, both of type double. The first represents an angle, from 0 to 360, and the second a scale factor, from 1 to 6.

home_page.dart [...] class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin { bool _loadingInProgress; Animation<double> _angleAnimation; Animation<double> _scaleAnimation; AnimationController _controller; @override void initState() { super.initState(); _loadingInProgress = true; _controller = new AnimationController( duration: const Duration(milliseconds: 2000), vsync: this); _angleAnimation = new Tween(begin: 0.0, end: 360.0).animate(_controller) ..addListener(() { setState(() { // the state that has changed here is the animation object’s value }); }); _scaleAnimation = new Tween(begin: 1.0, end: 6.0).animate(_controller) ..addListener(() { setState(() { // the state that has changed here is the animation object’s value }); }); _angleAnimation.addStatusListener((status) { if (status == AnimationStatus.completed) { if (_loadingInProgress) { _controller.reverse(); } } else if (status == AnimationStatus.dismissed) { if (_loadingInProgress) { _controller.forward(); } } }); _controller.forward(); _loadData(); } @override void dispose() { _controller.dispose(); super.dispose(); } [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 [ . . . ] class _HomePageState extends State < HomePage > with SingleTickerProviderStateMixin { bool _loadingInProgress ; Animation < double > _angleAnimation ; Animation < double > _scaleAnimation ; AnimationController _controller ; @ override void initState ( ) { super . initState ( ) ; _loadingInProgress = true ; _controller = new AnimationController ( duration : const Duration ( milliseconds : 2000 ) , vsync : this ) ; _angleAnimation = new Tween ( begin : 0.0 , end : 360.0 ) . animate ( _controller ) . . addListener ( ( ) { setState ( ( ) { // the state that has changed here is the animation object’s value } ) ; } ) ; _scaleAnimation = new Tween ( begin : 1.0 , end : 6.0 ) . animate ( _controller ) . . addListener ( ( ) { setState ( ( ) { // the state that has changed here is the animation object’s value } ) ; } ) ; _angleAnimation . addStatusListener ( ( status ) { if ( status == AnimationStatus . completed ) { if ( _loadingInProgress ) { _controller . reverse ( ) ; } } else if ( status == AnimationStatus . dismissed ) { if ( _loadingInProgress ) { _controller . forward ( ) ; } } } ) ; _controller . forward ( ) ; _loadData ( ) ; } @ override void dispose ( ) { _controller . dispose ( ) ; super . dispose ( ) ; } [ . . . ] }

Next, we’ll replace the CircularProgressIndicator with a custom UI, using the values from _angleAnimation and _scaleAnimation. It consists of 2 rows containing 2 circles each. All circles have the same size, and it depends on _scaleAnimation value. Then the 4 circles are rotated, using Transform.rotate, and the angle depends on _angleAnimation value.

home_page.dart [...] import 'dart:math'; [...] class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin { [...] Widget _buildBody() { if (_loadingInProgress) { return new Center( child: _buildAnimation(), ); } else { return new Center ( child: new Text('Data loaded'), ); } } Widget _buildAnimation() { double circleWidth = 10.0 * _scaleAnimation.value; Widget circles = new Container( width: circleWidth * 2.0, height: circleWidth * 2.0, child: new Column( children: <Widget>[ new Row ( children: <Widget>[ _buildCircle(circleWidth,Colors.blue), _buildCircle(circleWidth,Colors.red), ], ), new Row ( children: <Widget>[ _buildCircle(circleWidth,Colors.yellow), _buildCircle(circleWidth,Colors.green), ], ), ], ), ); double angleInDegrees = _angleAnimation.value; return new Transform.rotate( angle: angleInDegrees / 360 * 2 * PI, child: new Container( child: circles, ), ); } Widget _buildCircle(double circleWidth, Color color) { return new Container( width: circleWidth, height: circleWidth, decoration: new BoxDecoration( color: color, shape: BoxShape.circle, ), ); } [...] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 [ . . . ] import 'dart:math' ; [ . . . ] class _HomePageState extends State < HomePage > with SingleTickerProviderStateMixin { [ . . . ] Widget _buildBody ( ) { if ( _loadingInProgress ) { return new Center ( child : _buildAnimation ( ) , ) ; } else { return new Center ( child : new Text ( 'Data loaded' ) , ) ; } } Widget _buildAnimation ( ) { double circleWidth = 10.0 * _scaleAnimation . value ; Widget circles = new Container ( width : circleWidth * 2.0 , height : circleWidth * 2.0 , child : new Column ( children : < Widget > [ new Row ( children : < Widget > [ _buildCircle ( circleWidth , Colors . blue ) , _buildCircle ( circleWidth , Colors . red ) , ] , ) , new Row ( children : < Widget > [ _buildCircle ( circleWidth , Colors . yellow ) , _buildCircle ( circleWidth , Colors . green ) , ] , ) , ] , ) , ) ; double angleInDegrees = _angleAnimation . value ; return new Transform . rotate ( angle : angleInDegrees / 360 * 2 * PI , child : new Container ( child : circles , ) , ) ; } Widget _buildCircle ( double circleWidth , Color color ) { return new Container ( width : circleWidth , height : circleWidth , decoration : new BoxDecoration ( color : color , shape : BoxShape . circle , ) , ) ; } [ . . . ] }

And… that’s done! Pretty simple, no?

Note: just because such an animation is easy to do, it doesn’t mean you should start populating your app with custom loading animations! IMHO, their place is for an initial account sync. After that, your app should display cached data while getting the new one, and use platform UI conventions for refreshing as much as possible (eg pull down to refresh).

Voila

What next?

We used Row and Column to build the layout for the circles in this tutorial. For a reminder of how they work, check out “Flutter UI code tutorial: mastering Row and Column”.

Related