Capstone: Real-World Flutter Project

Animations & Polished Micro-Interactions

16 min Lesson 8 of 10

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),
        );
      },
    ),
  ],
);
Tip: Reuse a helper function 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,
    ),
  ),
)
Note: The 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),
)
Warning: 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, decoration
  • AnimatedOpacity — fade in/out based on a boolean flag
  • AnimatedScale / AnimatedRotation — scale or rotate a child
  • AnimatedDefaultTextStyle — smoothly change font size, weight, or color
  • AnimatedPositioned — slide children inside a Stack
  • TweenAnimationBuilder — 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.

Tip: Use 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 AnimationController for 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.builder on the main axis — it causes excessive layout passes. Animate only decorative properties (opacity, scale, color).
  • Use RepaintBoundary around 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.