skills/material3-expressive-flutter/references/motion-guide.md

12 KiB

Motion Guide - M3 Expressive

Overview

Material 3 Expressive introduces physics-based motion that feels natural, fluid, and alive. Motion uses spring-physics to mirror real-world object behavior.

Two Types of Springs

1. Spatial Springs

Purpose: Object movement and position changes
Characteristics: Natural bounce, overshoot, settle
Use for: Entering/exiting animations, drag interactions, page transitions

2. Effects Springs

Purpose: Visual property changes (color, opacity, scale)
Characteristics: Smooth, seamless transitions
Use for: Color shifts, fade in/out, scale changes

Spring Physics in Flutter

Using Curves.elasticOut (Approximates Spatial Spring)

AnimatedContainer(
  duration: const Duration(milliseconds: 600),
  curve: Curves.elasticOut, // Springy overshoot
  // ... properties
)

Custom Spring Simulation

import 'package:flutter/physics.dart';

class SpringyAnimationController extends StatefulWidget {
  const SpringyAnimationController({super.key});

  @override
  State<SpringyAnimationController> createState() =>
      _SpringyAnimationControllerState();
}

class _SpringyAnimationControllerState extends State<SpringyAnimationController>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );

    // Create spring simulation
    final spring = SpringDescription(
      mass: 1.0,        // Object mass
      stiffness: 100.0, // Spring stiffness (higher = stiffer)
      damping: 10.0,    // Damping ratio (higher = less bounce)
    );

    final simulation = SpringSimulation(spring, 0.0, 1.0, 0.0);

    _animation = Tween<double>(begin: 0, end: 1).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Cubic(0.34, 1.56, 0.64, 1), // Custom spring curve
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _trigger() {
    _controller.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _animation,
      child: FloatingActionButton(
        onPressed: _trigger,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Expressive Button Press Animation

class SpringyButton extends StatefulWidget {
  final VoidCallback onPressed;
  final Widget child;

  const SpringyButton({
    super.key,
    required this.onPressed,
    required this.child,
  });

  @override
  State<SpringyButton> createState() => _SpringyButtonState();
}

class _SpringyButtonState extends State<SpringyButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );

    _scaleAnimation = Tween<double>(
      begin: 1.0,
      end: 0.95,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Curves.elasticOut,
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _handleTapDown(TapDownDetails details) {
    _controller.forward();
  }

  void _handleTapUp(TapUpDetails details) {
    _controller.reverse();
    widget.onPressed();
  }

  void _handleTapCancel() {
    _controller.reverse();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: _handleTapDown,
      onTapUp: _handleTapUp,
      onTapCancel: _handleTapCancel,
      child: ScaleTransition(
        scale: _scaleAnimation,
        child: widget.child,
      ),
    );
  }
}

Color Transition (Effects Spring)

class ColorTransitionButton extends StatefulWidget {
  const ColorTransitionButton({super.key});

  @override
  State<ColorTransitionButton> createState() => _ColorTransitionButtonState();
}

class _ColorTransitionButtonState extends State<ColorTransitionButton> {
  bool _isActive = false;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return AnimatedContainer(
      duration: const Duration(milliseconds: 400),
      curve: Curves.easeInOut, // Smooth for color (Effects Spring)
      decoration: BoxDecoration(
        color: _isActive
            ? colorScheme.primaryContainer
            : colorScheme.surface,
        borderRadius: BorderRadius.circular(100),
        border: Border.all(
          color: _isActive
              ? colorScheme.primary
              : colorScheme.outline,
        ),
      ),
      child: Material(
        color: Colors.transparent,
        child: InkWell(
          onTap: () => setState(() => _isActive = !_isActive),
          borderRadius: BorderRadius.circular(100),
          child: Padding(
            padding: const EdgeInsets.symmetric(
              horizontal: 24,
              vertical: 12,
            ),
            child: AnimatedDefaultTextStyle(
              duration: const Duration(milliseconds: 400),
              curve: Curves.easeInOut,
              style: TextStyle(
                color: _isActive
                    ? colorScheme.onPrimaryContainer
                    : colorScheme.onSurface,
                fontWeight: _isActive ? FontWeight.bold : FontWeight.normal,
              ),
              child: const Text('Toggle Me'),
            ),
          ),
        ),
      ),
    );
  }
}

Page Transition with Spring

class SpringPageRoute extends PageRouteBuilder {
  final Widget page;

  SpringPageRoute({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            // Spatial spring for page movement
            const curve = Curves.elasticOut;
            var curvedAnimation = CurvedAnimation(
              parent: animation,
              curve: curve,
            );

            return SlideTransition(
              position: Tween<Offset>(
                begin: const Offset(1.0, 0.0),
                end: Offset.zero,
              ).animate(curvedAnimation),
              child: FadeTransition(
                opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
                  CurvedAnimation(
                    parent: animation,
                    curve: Curves.easeIn, // Effects spring for fade
                  ),
                ),
                child: child,
              ),
            );
          },
          transitionDuration: const Duration(milliseconds: 600),
        );
}

// Usage:
Navigator.of(context).push(
  SpringPageRoute(page: const DetailsPage()),
);

Common M3 Expressive Curves

// Spatial springs (movement)
Curves.elasticOut      // Strong bounce
Curves.elasticInOut    // Bounce both ways
Curves.bounceOut       // Pronounced bounce
Cubic(0.34, 1.56, 0.64, 1)  // Custom expressive curve

// Effects springs (properties)
Curves.easeOutCubic    // Smooth deceleration
Curves.easeInOut       // Smooth both ways
Curves.fastOutSlowIn   // Material standard

Morphing Shapes

class MorphingButton extends StatefulWidget {
  const MorphingButton({super.key});

  @override
  State<MorphingButton> createState() => _MorphingButtonState();
}

class _MorphingButtonState extends State<MorphingButton> {
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: const Duration(milliseconds: 400),
      curve: Curves.elasticOut,
      width: _isExpanded ? 200 : 56,
      height: 56,
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.primary,
        borderRadius: BorderRadius.circular(_isExpanded ? 28 : 28),
      ),
      child: Material(
        color: Colors.transparent,
        child: InkWell(
          onTap: () => setState(() => _isExpanded = !_isExpanded),
          borderRadius: BorderRadius.circular(28),
          child: Center(
            child: _isExpanded
                ? const Text(
                    'Extended FAB',
                    style: TextStyle(color: Colors.white),
                  )
                : const Icon(Icons.add, color: Colors.white),
          ),
        ),
      ),
    );
  }
}

Hero Animations with Spring

class SpringHero extends StatelessWidget {
  const SpringHero({super.key});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          PageRouteBuilder(
            pageBuilder: (context, animation, secondaryAnimation) {
              return const DetailPage();
            },
            transitionsBuilder:
                (context, animation, secondaryAnimation, child) {
              return FadeTransition(
                opacity: animation,
                child: child,
              );
            },
          ),
        );
      },
      child: Hero(
        tag: 'expressive-hero',
        createRectTween: (begin, end) {
          return MaterialRectCenterArcTween(begin: begin, end: end);
        },
        flightShuttleBuilder: (
          flightContext,
          animation,
          direction,
          fromContext,
          toContext,
        ) {
          return ScaleTransition(
            scale: Tween<double>(begin: 1.0, end: 1.1).animate(
              CurvedAnimation(
                parent: animation,
                curve: Curves.elasticOut,
              ),
            ),
            child: toContext.widget,
          );
        },
        child: Container(
          width: 100,
          height: 100,
          color: Colors.blue,
        ),
      ),
    );
  }
}

Best Practices

  1. Choose the Right Spring:

    • Spatial springs for position/layout changes
    • Effects springs for visual properties
  2. Duration Guidelines:

    • Quick interactions: 200-300ms
    • Standard transitions: 300-500ms
    • Page transitions: 500-700ms
    • Dramatic effects: 700-1000ms
  3. Overshoot Carefully:

    • Use elastic curves sparingly
    • Avoid for subtle interactions
    • Great for celebratory moments
  4. Respect Reduced Motion:

final reduceMotion = MediaQuery.of(context).disableAnimations;
final duration = reduceMotion
    ? Duration.zero
    : const Duration(milliseconds: 400);
  1. Performance:
    • Use RepaintBoundary for complex animations
    • Prefer Transform over layout changes
    • Profile with DevTools Timeline

Stagger Animations

class StaggeredListAnimation extends StatelessWidget {
  final List<Widget> children;

  const StaggeredListAnimation({
    super.key,
    required this.children,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: children.asMap().entries.map((entry) {
        final index = entry.key;
        final child = entry.value;
        
        return TweenAnimationBuilder<double>(
          tween: Tween(begin: 0.0, end: 1.0),
          duration: Duration(milliseconds: 300 + (index * 100)),
          curve: Curves.elasticOut,
          builder: (context, value, child) {
            return Transform.translate(
              offset: Offset(0, 50 * (1 - value)),
              child: Opacity(
                opacity: value,
                child: child,
              ),
            );
          },
          child: child,
        );
      }).toList(),
    );
  }
}

Resources

Testing Animations

testWidgets('Button has springy animation', (tester) async {
  await tester.pumpWidget(const MyApp());
  
  // Find button
  final button = find.byType(SpringyButton);
  
  // Tap and pump frames
  await tester.tap(button);
  await tester.pump(); // Start
  await tester.pump(const Duration(milliseconds: 150)); // Mid
  await tester.pump(const Duration(milliseconds: 150)); // End
  
  // Verify animation occurred
});