Animations & Polished Micro-Interactions
Animations & Polished Micro-Interactions
A great app is more than functional screens — it feels alive. Smooth page transitions, Hero animations that link screens visually, and subtle micro-interactions that respond to user gestures are the finishing touches that separate a polished product from a rough prototype. In this lesson you will learn to wire GoRouter page transitions, implement Hero animations, use AnimatedSwitcher for list state changes, and apply implicit animation widgets to craft fluid micro-interactions throughout your capstone app.
Page Transition Animations with GoRouter
GoRouter exposes a pageBuilder callback on each GoRoute that lets you return a custom Page instead of the default MaterialPage. By returning a CustomTransitionPage you control every pixel of the enter/exit animation.
// router/app_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../screens/home_screen.dart';
import '../screens/detail_screen.dart';
final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const HomeScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Fade + slight upward slide
final tween = Tween(begin: const Offset(0, 0.05), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeOutCubic));
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: animation.drive(tween),
child: child,
),
);
},
transitionDuration: const Duration(milliseconds: 300),
),
),
GoRoute(
path: '/detail/:id',
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return CustomTransitionPage(
key: state.pageKey,
child: DetailScreen(id: id),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Shared axis horizontal slide
final enterTween = Tween(
begin: const Offset(1.0, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeInOutCubic));
final exitTween = Tween(
begin: Offset.zero,
end: const Offset(-0.3, 0),
).chain(CurveTween(curve: Curves.easeInOutCubic));
return Stack(
children: [
SlideTransition(
position: secondaryAnimation.drive(exitTween),
child: Container(), // outgoing page handled by shell
),
SlideTransition(
position: animation.drive(enterTween),
child: child,
),
],
);
},
transitionDuration: const Duration(milliseconds: 350),
);
},
),
],
);
buildFadeSlide() that returns a CustomTransitionPage so every route shares the same transition style and you only tweak the curve or direction per route.Hero Animations
A Hero widget animates a shared visual element between two routes — the framework automatically tweens the widget’s position and size during navigation. Both the source and destination widgets must share the same tag.
// In the list item (source)
Hero(
tag: 'product-image-${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
product.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
)
// In the detail screen (destination) — same tag, larger size
Hero(
tag: 'product-image-${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
product.imageUrl,
width: double.infinity,
height: 260,
fit: BoxFit.cover,
),
),
)
tag must be unique across the entire widget tree at any given moment. Using the entity’s unique ID (e.g., 'product-image-\${product.id}') prevents tag collisions when multiple items are on screen simultaneously.AnimatedSwitcher for List State Changes
AnimatedSwitcher automatically plays a transition when its child widget is swapped for one with a different key. This is ideal for toggling between an empty state illustration and a populated list, or between a loading spinner and loaded content.
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionsBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: animation, child: child),
);
},
child: items.isEmpty
? const EmptyStateWidget(key: ValueKey('empty'))
: ProductListView(key: ValueKey('list'), items: items),
)
AnimatedSwitcher only detects a child change when the new child has a different key from the previous one. If you swap children without changing the key, no animation plays. Always assign a ValueKey (or UniqueKey) to each distinct child.Implicit Animation Widgets for Micro-Interactions
Flutter ships a family of Animated* implicit widgets that tween automatically whenever a property changes — no AnimationController needed. They watch their constructor arguments and animate any change over the specified duration.
AnimatedContainer— color, size, border-radius, padding, decorationAnimatedOpacity— fade in/out based on a boolean flagAnimatedScale/AnimatedRotation— scale or rotate a childAnimatedDefaultTextStyle— smoothly change font size, weight, or colorAnimatedPositioned— slide children inside aStackTweenAnimationBuilder— animate any value not covered by the above
// Micro-interaction: favourite button with scale + color pop
class FavouriteButton extends StatefulWidget {
final bool isFavourite;
final VoidCallback onToggle;
const FavouriteButton({
super.key,
required this.isFavourite,
required this.onToggle,
});
@override
State<FavouriteButton> createState() => _FavouriteButtonState();
}
class _FavouriteButtonState extends State<FavouriteButton> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onToggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.elasticOut,
padding: EdgeInsets.all(widget.isFavourite ? 12 : 8),
decoration: BoxDecoration(
color: widget.isFavourite
? Colors.red.shade50
: Colors.grey.shade100,
shape: BoxShape.circle,
),
child: AnimatedScale(
scale: widget.isFavourite ? 1.25 : 1.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: Icon(
widget.isFavourite ? Icons.favorite : Icons.favorite_border,
color: widget.isFavourite ? Colors.red : Colors.grey,
size: 24,
),
),
),
);
}
}
Combining Techniques: Polished List Item
Real polish comes from layering several techniques together. A list card can use Hero on its thumbnail, AnimatedContainer for a pressed highlight, and AnimatedOpacity to fade in when first mounted.
TweenAnimationBuilder<double> with begin: 0, end: 1 triggered on initState to stagger list-item entrance animations. Each item delays its animation start by index * 50ms for a cascading reveal effect.Performance Guidelines
- Prefer implicit animations (Animated* widgets) over explicit
AnimationControllerfor simple property tweens — they are less code and leak-proof. - Keep animation durations between 150ms and 400ms for UI feedback; longer durations feel sluggish.
- Avoid animating inside a
ListView.builderon the main axis — it causes excessive layout passes. Animate only decorative properties (opacity, scale, color). - Use
RepaintBoundaryaround heavy animated subtrees to isolate them in their own layer, reducing repaint area.
Summary
You now have a complete animation toolkit for your capstone app. GoRouter’s CustomTransitionPage controls page-level motion. Hero creates seamless visual continuity between screens. AnimatedSwitcher handles state-driven child swaps elegantly. And the implicit Animated* family delivers effortless micro-interactions — all without writing a single AnimationController. Apply these patterns judiciously: animation should clarify the UI, not distract from it.