How To Implement Flutter Staggered Animations
How to implement the Material Design stagger animations not in the SDK.
Material Design 3 introduced the skeleton staggered animation loading pattern for several containers, including ListViews. While it is nice that the Flutter Community has stepped in to fill the holes; those plugins offer free lessons in how to approach specific app design problems. Thus, it makes sense to show you how the skeleton staggered animation was implemented for containers including Listview.
How To Implement Staggered Animation
The staggered skeleton loading is called stagger in the Material Design 3 spec but it currently is not in the Material Design spec docs but it is in the outstanding issues to implement Material Design 3 spec in the Flutter SDK.
Let's start with the special case of ListView as have scrollable container which only the stuff on screen is rendered. That means we need to limit so that the offscreen items are not animated, hence the AnimatioLimiter class in the Flutter Staggered Animations package:
Flutter Staggered Animations Package
Okay, let me explain widget tree and scrollable in terms of frames. Pretend we have a list of 100 items. Obviously, after the first 5-to-10 the other items are not only offscreen but they also will not be in the first frame.
So as we go forward in frames if the user has scrolled then we want those new items in on-screen ListView wise to be animated via staggered animation. So SDK construct, does that imply that we need to use?
Yes, we need to use an Inherited Widget to reset the private shouldRunAnimation upon the difference between mounted and unmounted:
#
import 'package:flutter/widgets.dart'; | |
/// In the context of a scrollable view, your children's animations are only built | |
/// as the user scrolls and they appear on the screen. | |
/// | |
/// This create a situation | |
/// where your animations will be run as you scroll through the content. | |
/// | |
/// If this is not a behaviour you want in your app, you can use AnimationLimiter. | |
/// | |
/// AnimationLimiter is an InheritedWidget that prevents the children widgets to be | |
/// animated if they don't appear in the first frame where AnimationLimiter is built. | |
/// | |
/// To be effective, AnimationLimiter musts be a direct parent of your scrollable list of widgets. | |
class AnimationLimiter extends StatefulWidget { | |
/// The child Widget to animate. | |
final Widget child; | |
/// Creates an [AnimationLimiter] that will prevents the children widgets to be | |
/// animated if they don't appear in the first frame where AnimationLimiter is built. | |
/// | |
/// The [child] argument must not be null. | |
const AnimationLimiter({ | |
Key? key, | |
required this.child, | |
}) : super(key: key); | |
@override | |
State<AnimationLimiter> createState() => _AnimationLimiterState(); | |
static bool? shouldRunAnimation(BuildContext context) { | |
return _AnimationLimiterProvider.of(context)?.shouldRunAnimation; | |
} | |
} | |
class _AnimationLimiterState extends State<AnimationLimiter> { | |
bool _shouldRunAnimation = true; | |
@override | |
void initState() { | |
super.initState(); | |
WidgetsBinding.instance.addPostFrameCallback((Duration value) { | |
if (!mounted) return; | |
setState(() { | |
_shouldRunAnimation = false; | |
}); | |
}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return _AnimationLimiterProvider( | |
shouldRunAnimation: _shouldRunAnimation, | |
child: widget.child, | |
); | |
} | |
} | |
class _AnimationLimiterProvider extends InheritedWidget { | |
final bool? shouldRunAnimation; | |
const _AnimationLimiterProvider({ | |
this.shouldRunAnimation, | |
required Widget child, | |
}) : super(child: child); | |
@override | |
bool updateShouldNotify(InheritedWidget oldWidget) { | |
return false; | |
} | |
static _AnimationLimiterProvider? of(BuildContext context) { | |
return context.findAncestorWidgetOfExactType<_AnimationLimiterProvider>(); | |
} | |
} |
That handles in the ListView the scrollable, but what about the ListView items, how do they get animated? Since, we want to stagger animate ListView, GridView, Column, and Row we need to be smart and conceptualize what they are.
ListView is a column of one. GridView is several columns. Columns and Rows are just mapped lists in that each item can be staggered animated by addressing changes in things like start time and duration. Thus, that is at least 3 methods.
To put it another way, we are configuring the duration and delay per the model of the container, and we individually choose the animation as a wrapper around the item.
So the Animation Configuration class to wrap the containers is:
import 'package:flutter/widgets.dart'; | |
/// [AnimationConfiguration] provides the configuration used as a base for every children Animation. | |
/// Configuration made in [AnimationConfiguration] can be overridden in Animation children if needed. | |
/// | |
/// Depending on the scenario in which you will present your animations, | |
/// you should use one of [AnimationConfiguration]'s named constructors. | |
/// | |
/// [AnimationConfiguration.synchronized] if you want to launch all the children's animations at the same time. | |
/// | |
/// [AnimationConfiguration.staggeredList] if you want to delay the animation of each child | |
/// to produce a single-axis staggered animations (from top to bottom or from left to right). | |
/// | |
/// [AnimationConfiguration.staggeredGrid] if you want to delay the animation of each child | |
/// to produce a dual-axis staggered animations (from left to right and top to bottom). | |
class AnimationConfiguration extends InheritedWidget { | |
/// Index used as a factor to calculate the delay of each child's animation. | |
final int position; | |
/// The duration of each child's animation. | |
final Duration duration; | |
/// The delay between the beginning of two children's animations. | |
final Duration? delay; | |
/// The column count of the grid | |
final int columnCount; | |
/// Configure the children's animation to be synchronized (all the children's animation start at the same time). | |
/// | |
/// Default value for [duration] is 225ms. | |
/// | |
/// The [child] argument must not be null. | |
const AnimationConfiguration.synchronized({ | |
Key? key, | |
this.duration = const Duration(milliseconds: 225), | |
required Widget child, | |
}) : position = 0, | |
delay = Duration.zero, | |
columnCount = 1, | |
super(key: key, child: child); | |
/// Configure the children's animation to be staggered. | |
/// | |
/// A staggered animation consists of sequential or overlapping animations. | |
/// | |
/// Each child animation will start with a delay based on its position comparing to previous children. | |
/// | |
/// The staggering effect will be based on a single axis (from top to bottom or from left to right). | |
/// | |
/// Use this named constructor to display a staggered animation on a single-axis list of widgets | |
/// ([ListView], [ScrollView], [Column], [Row]...). | |
/// | |
/// The [position] argument must not be null. | |
/// | |
/// Default value for [duration] is 225ms. | |
/// | |
/// Default value for [delay] is the [duration] divided by 6 | |
/// (appropriate factor to keep coherence during the animation). | |
/// | |
/// The [child] argument must not be null. | |
const AnimationConfiguration.staggeredList({ | |
Key? key, | |
required this.position, | |
this.duration = const Duration(milliseconds: 225), | |
this.delay, | |
required Widget child, | |
}) : columnCount = 1, | |
super(key: key, child: child); | |
/// Configure the children's animation to be staggered. | |
/// | |
/// A staggered animation consists of sequential or overlapping animations. | |
/// | |
/// Each child animation will start with a delay based on its position comparing to previous children. | |
/// | |
/// The staggering effect will be based on a dual-axis (from left to right and top to bottom). | |
/// | |
/// Use this named constructor to display a staggered animation on a dual-axis list of widgets | |
/// ([GridView]...). | |
/// | |
/// The [position] argument must not be null. | |
/// | |
/// Default value for [duration] is 225ms. | |
/// | |
/// Default value for [delay] is the [duration] divided by 6 | |
/// (appropriate factor to keep coherence during the animation). | |
/// | |
/// The [columnCount] argument must not be null and must be greater than 0. | |
/// | |
/// The [child] argument must not be null. | |
const AnimationConfiguration.staggeredGrid({ | |
Key? key, | |
required this.position, | |
this.duration = const Duration(milliseconds: 225), | |
this.delay, | |
required this.columnCount, | |
required Widget child, | |
}) : super(key: key, child: child); | |
@override | |
bool updateShouldNotify(InheritedWidget oldWidget) { | |
return false; | |
} | |
/// Helper method to apply a staggered animation to the children of a [Column] or [Row]. | |
/// | |
/// It maps every child with an index and calls | |
/// [AnimationConfiguration.staggeredList] constructor under the hood. | |
/// | |
/// Default value for [duration] is 225ms. | |
/// | |
/// Default value for [delay] is the [duration] divided by 6 | |
/// (appropriate factor to keep coherence during the animation). | |
/// | |
/// The [childAnimationBuilder] is a function that will be applied to each child you provide in [children] | |
/// | |
/// The following is an example of a [childAnimationBuilder] you could provide: | |
/// | |
/// ```dart | |
/// (widget) => SlideAnimation( | |
/// horizontalOffset: 50.0, | |
/// child: FadeInAnimation( | |
/// child: widget, | |
/// ), | |
/// ) | |
/// ``` | |
/// | |
/// The [children] argument must not be null. | |
/// It corresponds to the children you would normally have passed to the [Column] or [Row]. | |
static List<Widget> toStaggeredList({ | |
Duration? duration, | |
Duration? delay, | |
required Widget Function(Widget) childAnimationBuilder, | |
required List<Widget> children, | |
}) => | |
children | |
.asMap() | |
.map((index, widget) { | |
return MapEntry( | |
index, | |
AnimationConfiguration.staggeredList( | |
position: index, | |
duration: duration ?? const Duration(milliseconds: 225), | |
delay: delay, | |
child: childAnimationBuilder(widget), | |
), | |
); | |
}) | |
.values | |
.toList(); | |
static AnimationConfiguration? of(BuildContext context) { | |
return context.findAncestorWidgetOfExactType<AnimationConfiguration>(); | |
} | |
} |
The other part is the Animation configurator class that wraps around the specific animation:
import 'package:flutter/widgets.dart'; | |
import 'animation_configuration.dart'; | |
import 'animation_executor.dart'; | |
class AnimationConfigurator extends StatelessWidget { | |
final Duration? duration; | |
final Duration? delay; | |
final Widget Function(Animation<double>) animatedChildBuilder; | |
const AnimationConfigurator({ | |
Key? key, | |
this.duration, | |
this.delay, | |
required this.animatedChildBuilder, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
final animationConfiguration = AnimationConfiguration.of(context); | |
if (animationConfiguration == null) { | |
throw FlutterError.fromParts( | |
<DiagnosticsNode>[ | |
ErrorSummary('Animation not wrapped in an AnimationConfiguration.'), | |
ErrorDescription( | |
'This error happens if you use an Animation that is not wrapped in an ' | |
'AnimationConfiguration.'), | |
ErrorHint( | |
'The solution is to wrap your Animation(s) with an AnimationConfiguration. ' | |
'Reminder: an AnimationConfiguration provides the configuration ' | |
'used as a base for every children Animation. Configuration made in AnimationConfiguration ' | |
'can be overridden in Animation children if needed.'), | |
], | |
); | |
} | |
final _position = animationConfiguration.position; | |
final _duration = duration ?? animationConfiguration.duration; | |
final _delay = delay ?? animationConfiguration.delay; | |
final _columnCount = animationConfiguration.columnCount; | |
return AnimationExecutor( | |
duration: _duration, | |
delay: stagger(_position, _duration, _delay, _columnCount), | |
builder: (context, animationController) => | |
animatedChildBuilder(animationController!), | |
); | |
} | |
Duration stagger( | |
int position, Duration duration, Duration? delay, int columnCount) { | |
var delayInMilliseconds = | |
(delay == null ? duration.inMilliseconds ~/ 6 : delay.inMilliseconds); | |
int _computeStaggeredGridDuration() { | |
return (position ~/ columnCount + position % columnCount) * | |
delayInMilliseconds; | |
} | |
int _computeStaggeredListDuration() { | |
return position * delayInMilliseconds; | |
} | |
return Duration( | |
milliseconds: columnCount > 1 | |
? _computeStaggeredGridDuration() | |
: _computeStaggeredListDuration()); | |
} | |
} |
The included animation classes then use that Animation Configurator class, for example the Fade In Animation class:
import 'package:flutter/widgets.dart'; | |
import 'animation_configurator.dart'; | |
/// An animation that fades its child. | |
class FadeInAnimation extends StatelessWidget { | |
/// The duration of the child animation. | |
final Duration? duration; | |
/// The delay between the beginning of two children's animations. | |
final Duration? delay; | |
/// The curve of the child animation. Defaults to [Curves.ease]. | |
final Curve curve; | |
/// The child Widget to animate. | |
final Widget child; | |
/// Creates a fade animation that fades its child. | |
/// | |
/// The [child] argument must not be null. | |
const FadeInAnimation({ | |
Key? key, | |
this.duration, | |
this.delay, | |
this.curve = Curves.ease, | |
required this.child, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return AnimationConfigurator( | |
duration: duration, | |
delay: delay, | |
animatedChildBuilder: _fadeInAnimation, | |
); | |
} | |
Widget _fadeInAnimation(Animation<double> animation) { | |
final _opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate( | |
CurvedAnimation( | |
parent: animation, | |
curve: Interval(0.0, 1.0, curve: curve), | |
), | |
); | |
return Opacity( | |
opacity: _opacityAnimation.value, | |
child: child, | |
); | |
} | |
} |
Now, let's see how it is used in a sample app.
Sample App Code
This is the Card List Screen:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// | |
// Original Copyright (c) 2019 mobiten under MIT License | |
import 'dart:async'; | |
import 'package:flutter/material.dart'; | |
/// Automatically rebuild [child] widget after the given [duration] | |
class AutoRefresh extends StatefulWidget { | |
final Duration duration; | |
final Widget child; | |
const AutoRefresh({ | |
Key? key, | |
required this.duration, | |
required this.child, | |
}) : super(key: key); | |
@override | |
State<AutoRefresh> createState() => _AutoRefreshState(); | |
} | |
class _AutoRefreshState extends State<AutoRefresh> { | |
int? keyValue; | |
ValueKey? key; | |
Timer? _timer; | |
@override | |
void initState() { | |
super.initState(); | |
keyValue = 0; | |
key = ValueKey(keyValue); | |
_recursiveBuild(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
key: key, | |
child: widget.child, | |
); | |
} | |
void _recursiveBuild() { | |
_timer = Timer( | |
widget.duration, | |
() { | |
setState(() { | |
keyValue = keyValue! + 1; | |
key = ValueKey(keyValue); | |
_recursiveBuild(); | |
}); | |
}, | |
); | |
} | |
@override | |
void dispose() { | |
_timer?.cancel(); | |
super.dispose(); | |
} | |
} |
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// | |
// Original Copyright (c) 2019 mobiten under MIT License | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; | |
import '../widgets/auto_refresh.dart'; | |
import '../widgets/empty_card.dart'; | |
class CardListScreen extends StatefulWidget { | |
const CardListScreen({Key? key}) : super(key: key); | |
@override | |
State<CardListScreen> createState() => _CardListScreenState(); | |
} | |
class _CardListScreenState extends State<CardListScreen> { | |
@override | |
Widget build(BuildContext context) { | |
return AutoRefresh( | |
duration: const Duration(milliseconds: 2000), | |
child: Scaffold( | |
body: SafeArea( | |
child: AnimationLimiter( | |
child: ListView.builder( | |
padding: const EdgeInsets.all(8.0), | |
itemCount: 100, | |
itemBuilder: (BuildContext context, int index) { | |
return AnimationConfiguration.staggeredList( | |
position: index, | |
duration: const Duration(milliseconds: 375), | |
child: SlideAnimation( | |
verticalOffset: 44.0, | |
child: FadeInAnimation( | |
child: EmptyCard( | |
width: MediaQuery.of(context).size.width, | |
height: 88.0, | |
), | |
), | |
), | |
); | |
}, | |
), | |
), | |
), | |
), | |
); | |
} | |
} |
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// | |
// Original Copyright (c) 2019 mobiten under MIT License | |
import 'package:flutter/material.dart'; | |
class EmptyCard extends StatelessWidget { | |
final double? width; | |
final double? height; | |
const EmptyCard({ | |
Key? key, | |
this.width, | |
this.height, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
width: width, | |
height: height, | |
margin: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), | |
decoration: const BoxDecoration( | |
color: Colors.white, | |
borderRadius: BorderRadius.all(Radius.circular(4.0)), | |
boxShadow: <BoxShadow>[ | |
BoxShadow( | |
color: Colors.black12, | |
blurRadius: 4.0, | |
offset: Offset(0.0, 4.0), | |
), | |
], | |
), | |
); | |
} | |
} |
The sequence for ListView is to wrap it in the AnimationLimiter widget and then wrap the animation used from the package in a specific AnimationConfiguration method named staggeredList.
GridView is similar to how we did it with ListView:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// | |
// Original Copyright (c) 2019 mobiten under MIT License | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; | |
import '../widgets/auto_refresh.dart'; | |
import '../widgets/empty_card.dart'; | |
class CardGridScreen extends StatefulWidget { | |
const CardGridScreen({Key? key}) : super(key: key); | |
@override | |
State<CardGridScreen> createState() => _CardGridScreenState(); | |
} | |
class _CardGridScreenState extends State<CardGridScreen> { | |
@override | |
Widget build(BuildContext context) { | |
var columnCount = 3; | |
return AutoRefresh( | |
duration: const Duration(milliseconds: 2000), | |
child: Scaffold( | |
body: SafeArea( | |
child: AnimationLimiter( | |
child: GridView.count( | |
childAspectRatio: 1.0, | |
padding: const EdgeInsets.all(8.0), | |
crossAxisCount: columnCount, | |
children: List.generate( | |
100, | |
(int index) { | |
return AnimationConfiguration.staggeredGrid( | |
columnCount: columnCount, | |
position: index, | |
duration: const Duration(milliseconds: 375), | |
child: const ScaleAnimation( | |
scale: 0.5, | |
child: FadeInAnimation( | |
child: EmptyCard(), | |
), | |
), | |
); | |
}, | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
} |
And Card Column example:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
// | |
// Original Copyright (c) 2019 mobiten under MIT License | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; | |
import '../widgets/auto_refresh.dart'; | |
import '../widgets/empty_card.dart'; | |
class CardColumnScreen extends StatefulWidget { | |
const CardColumnScreen({Key? key}) : super(key: key); | |
@override | |
State<CardColumnScreen> createState() => _CardColumnScreenState(); | |
} | |
class _CardColumnScreenState extends State<CardColumnScreen> { | |
@override | |
Widget build(BuildContext context) { | |
return AutoRefresh( | |
duration: const Duration(milliseconds: 2000), | |
child: Scaffold( | |
body: SafeArea( | |
child: SingleChildScrollView( | |
padding: const EdgeInsets.all(16.0), | |
child: AnimationLimiter( | |
child: Column( | |
children: AnimationConfiguration.toStaggeredList( | |
duration: const Duration(milliseconds: 375), | |
childAnimationBuilder: (widget) => SlideAnimation( | |
horizontalOffset: MediaQuery.of(context).size.width / 2, | |
child: FadeInAnimation(child: widget), | |
), | |
children: [ | |
EmptyCard( | |
width: MediaQuery.of(context).size.width, | |
height: 166.0, | |
), | |
const Padding( | |
padding: EdgeInsets.symmetric(vertical: 8.0), | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.spaceEvenly, | |
children: [ | |
EmptyCard(height: 50.0, width: 50.0), | |
EmptyCard(height: 50.0, width: 50.0), | |
EmptyCard(height: 50.0, width: 50.0), | |
], | |
), | |
), | |
const Row( | |
children: [ | |
Flexible(child: EmptyCard(height: 150.0)), | |
Flexible(child: EmptyCard(height: 150.0)), | |
], | |
), | |
const Padding( | |
padding: EdgeInsets.symmetric(vertical: 8.0), | |
child: Row( | |
children: [ | |
Flexible(child: EmptyCard(height: 50.0)), | |
Flexible(child: EmptyCard(height: 50.0)), | |
Flexible(child: EmptyCard(height: 50.0)), | |
], | |
), | |
), | |
EmptyCard( | |
width: MediaQuery.of(context).size.width, | |
height: 166.0, | |
), | |
], | |
), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
} |
Thoughts
That is one of the ways that the Material Design 3 stagger container content animation can be implemented. It is often nicknamed the skeleton loading animation in flutter app design discussions.
Fred Grott's Newsletter
Code: Flutter Bytes Hub
Demos-at-YouTube: YouTube Channel
An ADHD'ers super focus on Flutter app design and the side hustle of designing flutter apps. Delivered, several days every weekday to your inbox.