12 KiB
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
-
Choose the Right Spring:
- Spatial springs for position/layout changes
- Effects springs for visual properties
-
Duration Guidelines:
- Quick interactions: 200-300ms
- Standard transitions: 300-500ms
- Page transitions: 500-700ms
- Dramatic effects: 700-1000ms
-
Overshoot Carefully:
- Use elastic curves sparingly
- Avoid for subtle interactions
- Great for celebratory moments
-
Respect Reduced Motion:
final reduceMotion = MediaQuery.of(context).disableAnimations;
final duration = reduceMotion
? Duration.zero
: const Duration(milliseconds: 400);
- Performance:
- Use
RepaintBoundaryfor complex animations - Prefer
Transformover layout changes - Profile with DevTools Timeline
- Use
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
});