Animations
Animations bring your application to life. This guide explores how to create smooth animations using Flutter Compositions, including basic animations, staggered animations, reactive animation control, and animation patterns.
Why Use Composables for Animations?
In traditional Flutter, animation controllers require manual lifecycle management:
dart
// ❌ Traditional approach - lots of boilerplate
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 1),
);
}
@override
void dispose() {
_controller.dispose(); // Don't forget!
super.dispose();
}
}
// ✅ Compositions approach - concise and safe
@override
Widget Function(BuildContext) setup() {
final (controller, animValue) = useAnimationController(
duration: Duration(seconds: 1),
);
// Automatically disposed!
return (context) => /* ... */;
}useAnimationController - Basic Animations
useAnimationController creates an AnimationController with automatic disposal and reactive value tracking.
Simple Fade In
dart
class FadeInWidget extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, animValue) = useAnimationController(
duration: Duration(milliseconds: 500),
);
onMounted(() {
controller.forward();
});
return (context) => FadeTransition(
opacity: controller,
child: Text('Hello, World!'),
);
}
}Using Reactive Values
dart
class PulsingHeart extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, animValue) = useAnimationController(
duration: Duration(milliseconds: 800),
);
onMounted(() {
controller.repeat(reverse: true);
});
return (context) => Transform.scale(
scale: 1.0 + (animValue.value * 0.2), // 1.0 to 1.2
child: Icon(Icons.favorite, size: 100, color: Colors.red),
);
}
}Rotation Animation
dart
class SpinningIcon extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, animValue) = useAnimationController(
duration: Duration(seconds: 2),
);
onMounted(() {
controller.repeat();
});
return (context) => Transform.rotate(
angle: animValue.value * 2 * pi,
child: Icon(Icons.refresh, size: 48),
);
}
}Interpolated Animations
Use Tween to interpolate between values.
Size Animation
dart
class GrowingBox extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 600),
);
final sizeAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 50, end: 200),
);
void toggle() {
if (controller.status == AnimationStatus.completed) {
controller.reverse();
} else {
controller.forward();
}
}
return (context) => GestureDetector(
onTap: toggle,
child: AnimatedBuilder(
animation: sizeAnimation,
builder: (context, child) => Container(
width: sizeAnimation.value,
height: sizeAnimation.value,
color: Colors.blue,
),
),
);
}
}Color Animation
dart
class ColorChangingBox extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, _) = useAnimationController(
duration: Duration(seconds: 2),
);
final colorAnimation = manageAnimation(
parent: controller,
tween: ColorTween(
begin: Colors.blue,
end: Colors.purple,
),
);
onMounted(() => controller.repeat(reverse: true));
return (context) => AnimatedBuilder(
animation: colorAnimation,
builder: (context, child) => Container(
width: 200,
height: 200,
color: colorAnimation.value,
),
);
}
}Multiple Animations
Animate multiple properties simultaneously:
dart
class AnimatedCard extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 800),
);
final sizeAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 100, end: 200),
);
final colorAnimation = manageAnimation(
parent: controller,
tween: ColorTween(begin: Colors.blue, end: Colors.purple),
);
final borderRadiusAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0, end: 50),
);
final rotationAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0, end: 0.1),
);
void toggle() {
if (controller.isCompleted) {
controller.reverse();
} else {
controller.forward();
}
}
return (context) => GestureDetector(
onTap: toggle,
child: AnimatedBuilder(
animation: controller,
builder: (context, child) => Transform.rotate(
angle: rotationAnimation.value,
child: Container(
width: sizeAnimation.value,
height: sizeAnimation.value,
decoration: BoxDecoration(
color: colorAnimation.value,
borderRadius: BorderRadius.circular(
borderRadiusAnimation.value,
),
),
),
),
),
);
}
}Curved Animations
Use easing curves to make animations more natural.
Using Built-in Curves
dart
class BouncingBox extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 1000),
);
final curvedAnimation = CurvedAnimation(
parent: controller,
curve: Curves.elasticOut, // Elastic easing
);
final scaleAnimation = manageAnimation(
parent: curvedAnimation,
tween: Tween<double>(begin: 0, end: 1),
);
onMounted(() => controller.forward());
return (context) => AnimatedBuilder(
animation: scaleAnimation,
builder: (context, child) => Transform.scale(
scale: scaleAnimation.value,
child: Container(
width: 100,
height: 100,
color: Colors.blue,
),
),
);
}
}Different Curves for Different Phases
dart
class ComplexCurveBox extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, _) = useAnimationController(
duration: Duration(seconds: 2),
);
final curvedAnimation = CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
reverseCurve: Curves.bounceOut, // Use different curve when reversing
);
final positionAnimation = manageAnimation(
parent: curvedAnimation,
tween: Tween<Offset>(
begin: Offset.zero,
end: Offset(1, 0),
),
);
onMounted(() => controller.repeat(reverse: true));
return (context) => SlideTransition(
position: positionAnimation,
child: Container(
width: 100,
height: 100,
color: Colors.blue,
),
);
}
}Staggered Animations
Create complex sequenced animations.
Sequential Transitions
dart
class StaggeredAnimation extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 2000),
);
// 0.0 - 0.3: Fade in
final fadeAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0, end: 1),
);
// 0.2 - 0.6: Slide
final slideAnimation = manageAnimation(
parent: CurvedAnimation(
parent: controller,
curve: Interval(0.2, 0.6, curve: Curves.easeOut),
),
tween: Tween<Offset>(
begin: Offset(0, 0.5),
end: Offset.zero,
),
);
// 0.5 - 1.0: Scale
final scaleAnimation = manageAnimation(
parent: CurvedAnimation(
parent: controller,
curve: Interval(0.5, 1.0, curve: Curves.elasticOut),
),
tween: Tween<double>(begin: 0.5, end: 1.0),
);
onMounted(() => controller.forward());
return (context) => AnimatedBuilder(
animation: controller,
builder: (context, child) => FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Transform.scale(
scale: scaleAnimation.value,
child: Container(
width: 200,
height: 200,
color: Colors.blue,
child: Center(
child: Text(
'Hello!',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
),
),
),
);
}
}Staggered List Items
dart
class StaggeredListAnimation extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 1200),
);
onMounted(() => controller.forward());
return (context) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
// Each item has a slight delay
final start = index * 0.1;
final end = start + 0.4;
final animation = CurvedAnimation(
parent: controller,
curve: Interval(start, end, curve: Curves.easeOut),
);
return SlideTransition(
position: Tween<Offset>(
begin: Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: ListTile(title: Text(items[index])),
),
);
},
);
}
}Reactive Animation Control
Control animations using reactive state.
Toggle Animation
dart
class ExpandableCard extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final isExpanded = ref(false);
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 300),
);
final heightAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 100, end: 300),
);
// Watch state changes and control animation
watch(
() => isExpanded.value,
(expanded, _) {
if (expanded) {
controller.forward();
} else {
controller.reverse();
}
},
);
return (context) => Column(
children: [
ElevatedButton(
onPressed: () => isExpanded.value = !isExpanded.value,
child: Text(isExpanded.value ? 'Collapse' : 'Expand'),
),
AnimatedBuilder(
animation: heightAnimation,
builder: (context, child) => Container(
width: 300,
height: heightAnimation.value,
color: Colors.blue,
child: Center(
child: Text(
'Expandable Content',
style: TextStyle(color: Colors.white),
),
),
),
),
],
);
}
}Condition-Based Animation
dart
class ConditionalAnimation extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final status = ref<Status>(Status.idle);
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 500),
);
final colorAnimation = manageAnimation(
parent: controller,
tween: ColorTween(begin: Colors.grey, end: Colors.green),
);
watch(
() => status.value,
(newStatus, _) {
switch (newStatus) {
case Status.loading:
controller.repeat();
case Status.success:
controller.forward();
case Status.error:
controller.reverse();
case Status.idle:
controller.reset();
}
},
);
return (context) => AnimatedBuilder(
animation: colorAnimation,
builder: (context, child) => Container(
width: 100,
height: 100,
color: colorAnimation.value,
),
);
}
}
enum Status { idle, loading, success, error }Data-Driven Animation
dart
class DataDrivenChart extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final value = ref(0.0);
final targetValue = ref(75.0);
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 800),
);
final valueAnimation = manageAnimation(
parent: CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
),
tween: Tween<double>(begin: 0, end: 100),
);
// Watch target value changes
watch(
() => targetValue.value,
(target, _) {
valueAnimation.tween = Tween<double>(
begin: value.value,
end: target,
);
controller.forward(from: 0);
},
);
// Update current value
watchEffect(() {
value.value = valueAnimation.value;
});
return (context) => Column(
children: [
// Progress bar
AnimatedBuilder(
animation: valueAnimation,
builder: (context, child) => LinearProgressIndicator(
value: valueAnimation.value / 100,
),
),
// Current value
Text('${value.value.toStringAsFixed(1)}%'),
// Controls
Slider(
value: targetValue.value,
min: 0,
max: 100,
onChanged: (v) => targetValue.value = v,
),
],
);
}
}Real-World Examples
Loading Indicator
dart
class CustomLoader extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, animValue) = useAnimationController(
duration: Duration(milliseconds: 1500),
);
onMounted(() => controller.repeat());
final dots = [0, 1, 2];
return (context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: dots.map((index) {
// Each dot has a different delay
final delay = index * 0.2;
final scale = sin((animValue.value + delay) * 2 * pi) * 0.5 + 1;
return Transform.scale(
scale: scale,
child: Container(
width: 10,
height: 10,
margin: EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
);
}).toList(),
);
}
}Swipe to Dismiss
dart
class SwipeToDismiss extends CompositionWidget {
const SwipeToDismiss({required this.child, required this.onDismissed});
final Widget child;
final VoidCallback onDismissed;
@override
Widget Function(BuildContext) setup() {
final offset = ref(Offset.zero);
final isDragging = ref(false);
final (controller, _) = useAnimationController(
duration: Duration(milliseconds: 200),
);
final slideAnimation = manageAnimation(
parent: controller,
tween: Tween<Offset>(begin: Offset.zero, end: Offset(-1, 0)),
);
void onPanStart(DragStartDetails details) {
isDragging.value = true;
controller.stop();
}
void onPanUpdate(DragUpdateDetails details) {
offset.value = Offset(
(offset.value.dx + details.delta.dx).clamp(-300, 0),
0,
);
}
void onPanEnd(DragEndDetails details) {
isDragging.value = false;
if (offset.value.dx < -100) {
// Swipe distance is sufficient, dismiss
controller.forward().then((_) => onDismissed());
} else {
// Snap back
offset.value = Offset.zero;
}
}
final props = widget();
return (context) => GestureDetector(
onPanStart: onPanStart,
onPanUpdate: onPanUpdate,
onPanEnd: onPanEnd,
child: AnimatedBuilder(
animation: controller,
builder: (context, child) {
final currentOffset = isDragging.value
? offset.value
: slideAnimation.value;
return Transform.translate(
offset: currentOffset,
child: props.value.child,
);
},
),
);
}
}Pull to Refresh
dart
class PullToRefresh extends CompositionWidget {
const PullToRefresh({required this.onRefresh, required this.child});
final Future<void> Function() onRefresh;
final Widget child;
@override
Widget Function(BuildContext) setup() {
final scrollController = useScrollController();
final pullDistance = ref(0.0);
final isRefreshing = ref(false);
final (controller, animValue) = useAnimationController(
duration: Duration(milliseconds: 500),
);
final rotationAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0, end: 2 * pi),
);
void checkRefresh() {
if (pullDistance.value > 80 && !isRefreshing.value) {
isRefreshing.value = true;
controller.repeat();
onRefresh().then((_) {
isRefreshing.value = false;
controller.stop();
pullDistance.value = 0;
});
}
}
final props = widget();
return (context) => NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification) {
if (scrollController.value.position.pixels < 0) {
pullDistance.value = -scrollController.value.position.pixels;
}
} else if (notification is ScrollEndNotification) {
checkRefresh();
}
return false;
},
child: Stack(
children: [
ListView(
controller: scrollController.raw, // .raw avoids unnecessary rebuilds
children: [props.value.child],
),
if (pullDistance.value > 0)
Positioned(
top: pullDistance.value - 40,
left: 0,
right: 0,
child: Center(
child: AnimatedBuilder(
animation: rotationAnimation,
builder: (context, child) => Transform.rotate(
angle: isRefreshing.value
? rotationAnimation.value
: pullDistance.value / 80 * pi,
child: Icon(Icons.refresh, size: 32),
),
),
),
),
],
),
);
}
}Parallax Scrolling
dart
class ParallaxScroll extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final scrollController = useScrollController();
final scrollOffset = ref(0.0);
watchEffect(() {
scrollOffset.value = scrollController.value.offset;
});
return (context) => CustomScrollView(
controller: scrollController.raw, // .raw avoids unnecessary rebuilds
slivers: [
SliverAppBar(
expandedHeight: 300,
flexibleSpace: FlexibleSpaceBar(
background: Transform.translate(
offset: Offset(0, scrollOffset.value * 0.5), // Parallax effect
child: Image.network(
'https://example.com/image.jpg',
fit: BoxFit.cover,
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 50,
),
),
],
);
}
}Animation Composition
Create reusable animation composables.
dart
// Reusable fade-in animation composable
(AnimationController, Animation<double>) useFadeIn({
Duration duration = const Duration(milliseconds: 300),
bool autoStart = true,
}) {
final (controller, _) = useAnimationController(duration: duration);
final fadeAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0, end: 1),
);
if (autoStart) {
onMounted(() => controller.forward());
}
return (controller, fadeAnimation);
}
// Usage
class MyWidget extends CompositionWidget {
@override
Widget Function(BuildContext) setup() {
final (controller, fadeAnimation) = useFadeIn();
return (context) => FadeTransition(
opacity: fadeAnimation,
child: Text('Fading in!'),
);
}
}Performance Considerations
1. Use const Widgets
dart
// ✅ Good - const widget won't rebuild
return AnimatedBuilder(
animation: animation,
builder: (context, child) => Transform.scale(
scale: animation.value,
child: child,
),
child: const ExpensiveWidget(), // const!
);2. Limit AnimatedBuilder Scope
dart
// ❌ Bad - entire tree rebuilds
return AnimatedBuilder(
animation: controller,
builder: (context, child) => Column(
children: [
Transform.scale(scale: controller.value, child: Icon(Icons.star)),
ExpensiveWidget(),
AnotherExpensiveWidget(),
],
),
);
// ✅ Good - only animated part rebuilds
return Column(
children: [
AnimatedBuilder(
animation: controller,
builder: (context, child) => Transform.scale(
scale: controller.value,
child: Icon(Icons.star),
),
),
const ExpensiveWidget(),
const AnotherExpensiveWidget(),
],
);3. Reuse Animations
dart
// ✅ Good - single controller, multiple animations
final (controller, _) = useAnimationController(
duration: Duration(seconds: 1),
);
final scaleAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0.5, end: 1.0),
);
final rotationAnimation = manageAnimation(
parent: controller,
tween: Tween<double>(begin: 0, end: 2 * pi),
);Best Practices
1. Always Use useAnimationController
dart
// ✅ Good - automatic disposal
final (controller, animValue) = useAnimationController(
duration: Duration(seconds: 1),
);
// ❌ Bad - manual disposal
final vsync = useSingleTickerProvider();
final controller = AnimationController(vsync: vsync, duration: Duration(seconds: 1));
onUnmounted(() => controller.dispose());2. Use Reactive State to Control Animations
dart
// ✅ Good - declarative
final isPlaying = ref(false);
watch(() => isPlaying.value, (playing, _) {
if (playing) {
controller.repeat();
} else {
controller.stop();
}
});
// ❌ Bad - imperative
void toggleAnimation() {
if (controller.isAnimating) {
controller.stop();
} else {
controller.repeat();
}
}3. Use Curves for Complex Animations
dart
// ✅ Good - more natural animation
final curvedAnimation = CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
);
// ❌ Bad - linear animation feels mechanical
// Using controller directly4. Clean Up Properly
dart
// ✅ Good - composables handle automatically
final (controller, _) = useAnimationController(/* ... */);
// ❌ Bad - manual management
final controller = AnimationController(/* ... */);
onUnmounted(() {
controller.dispose();
// Easy to forget!
});Next Steps
- Explore Form Handling to build reactive forms
- Learn Async Operations to handle asynchronous animations
- Read the useAnimationController API for the complete API reference