Flutter Animation Patterns
Implicit Animations (prefer for simple transitions)
// AnimatedContainer, AnimatedOpacity, AnimatedPositioned, etc.
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 200 : 100,
height: _expanded ? 200 : 100,
color: _expanded ? Colors.blue : Colors.red,
child: const FlutterLogo(),
);
// AnimatedSwitcher: animate between two different widgets
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: Text('$_count', key: ValueKey(_count)),
);
// TweenAnimationBuilder: custom implicit animation
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _progress),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
builder: (context, value, child) =>
LinearProgressIndicator(value: value),
);
Explicit Animations with AnimationController
class PulseWidget extends StatefulWidget {
const PulseWidget({super.key});
@override
State<PulseWidget> createState() => _PulseWidgetState();
}
class _PulseWidgetState extends State<PulseWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
_scale = Tween(begin: 1.0, end: 1.2).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_ctrl.dispose(); // Always dispose!
super.dispose();
}
@override
Widget build(BuildContext context) => ScaleTransition(
scale: _scale,
child: const FlutterLogo(size: 100),
);
}
Staggered Animations
class _StaggeredState extends State<StaggeredList>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final List<Animation<Offset>> _slides = List.generate(5, (i) {
final start = i * 0.15;
return Tween(begin: const Offset(1, 0), end: Offset.zero).animate(
CurvedAnimation(
parent: _ctrl,
curve: Interval(start, (start + 0.4).clamp(0, 1), curve: Curves.easeOut),
),
);
});
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200));
_ctrl.forward();
}
@override
Widget build(BuildContext context) => Column(
children: List.generate(5, (i) => SlideTransition(
position: _slides[i],
child: ListTile(title: Text('Item $i')),
)),
);
}
Hero Animations
// Source page
GestureDetector(
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage())),
child: Hero(
tag: 'product-${product.id}', // must be unique across tree
child: Image.network(product.imageUrl, width: 80),
),
);
// Destination page
Hero(
tag: 'product-${product.id}',
child: Image.network(product.imageUrl, width: double.infinity),
);
Custom Page Transitions
class SlideUpRoute extends PageRouteBuilder {
SlideUpRoute({required this.page})
: super(
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) => SlideTransition(
position: Tween(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
),
);
final Widget page;
}
// GoRouter custom transition
GoRoute(
path: '/modal',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const ModalPage(),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
),
),
Lottie Animations
// pubspec: lottie: ^3.0.0
import 'package:lottie/lottie.dart';
// From assets
Lottie.asset('assets/animations/loading.json', width: 200, repeat: true);
// Controlled
LottieBuilder.asset(
'assets/animations/success.json',
controller: _ctrl,
onLoaded: (comp) {
_ctrl.duration = comp.duration;
_ctrl.forward();
},
);
Rive Animations
// pubspec: rive: ^0.13.0
RiveAnimation.asset(
'assets/animations/character.riv',
animations: const ['idle'],
onInit: (artboard) {
final controller = StateMachineController.fromArtboard(artboard, 'Main');
artboard.addController(controller!);
_runInput = controller.findInput<bool>('isRunning') as SMIBool;
},
);
// Trigger state machine input
_runInput?.change(true);
Reduced Motion
// Respect accessibility settings
class AccessibleAnimation extends StatelessWidget {
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
return AnimatedContainer(
duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 300),
child: const Placeholder(),
);
}
}