skills/flutter-animations/references/physics.md

14 KiB

Physics-Based Animations Reference

Physics-based animations create natural-feeling motion using simulations like springs, gravity, and momentum.

Core Concept

Instead of linear interpolation, use physics simulations for realistic motion that responds naturally to forces like velocity and damping.

Fling Animation

Basic Fling

class FlingWidget extends StatefulWidget {
  @override
  State<FlingWidget> createState() => _FlingWidgetState();
}

class _FlingWidgetState extends State<FlingWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    );
    _controller.fling(velocity: 2.0);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.translate(
          offset: Offset(_controller.value * 100, 0),
          child: child,
        );
      },
      child: const FlutterLogo(),
    );
  }
}

Fling with Gesture

class DraggableFling extends StatefulWidget {
  @override
  State<DraggableFling> createState() => _DraggableFlingState();
}

class _DraggableFlingState extends State<DraggableFling>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  double _dragX = 0;
  Offset? _startPosition;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController.unbounded(vsync: this);
  }

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

  void _handlePanStart(DragStartDetails details) {
    _controller.stop();
    _startPosition = details.globalPosition;
  }

  void _handlePanUpdate(DragUpdateDetails details) {
    if (_startPosition != null) {
      setState(() {
        _dragX += details.delta.dx;
      });
    }
  }

  void _handlePanEnd(DragEndDetails details) {
    _controller.fling(velocity: details.velocity.pixelsPerSecond.dx / 1000);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanStart: _handlePanStart,
      onPanUpdate: _handlePanUpdate,
      onPanEnd: _handlePanEnd,
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform.translate(
            offset: Offset(_dragX + _controller.value, 0),
            child: child,
          );
        },
        child: const FlutterLogo(),
      ),
    );
  }
}

Spring Simulation

Basic Spring

class SpringAnimation extends StatefulWidget {
  @override
  State<SpringAnimation> createState() => _SpringAnimationState();
}

class _SpringAnimationState extends State<SpringAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController.unbounded(vsync: this);

    _controller.animateWith(
      SpringSimulation(
        spring: const SpringDescription(
          mass: 1.0,
          stiffness: 200.0,
          damping: 10.0,
        ),
        start: 0.0,
        end: 1.0,
        velocity: 0.0,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.scale(
          scale: _controller.value,
          child: child,
        );
      },
      child: const FlutterLogo(),
    );
  }
}

Spring Description Parameters

SpringDescription(
  mass: 1.0,        // Mass of the object (higher = more inertia)
  stiffness: 200.0,   // Spring stiffness (higher = faster, stiffer)
  damping: 10.0,      // Damping ratio (higher = less oscillation)
)
)

Mass:

  • How "heavy" the object feels
  • Typical values: 0.5 - 5.0
  • Higher mass = more momentum, slower response

Stiffness:

  • How stiff the spring is
  • Typical values: 100 - 500
  • Higher stiffness = faster oscillation
  • Lower stiffness = slower, more "bouncy"

Damping:

  • How quickly oscillation stops
  • Typical values: 5 - 20
  • Higher damping = less bounce
  • Lower damping = more bounce
  • Critical damping: ~15-18

Spring Presets

Bouncy:

SpringDescription(
  mass: 0.5,
  stiffness: 300,
  damping: 8,
)

Snappy:

SpringDescription(
  mass: 1.0,
  stiffness: 400,
  damping: 18,
)

Gentle:

SpringDescription(
  mass: 2.0,
  stiffness: 150,
  damping: 20,
)

Gravity Simulation

Falling Animation

class GravityAnimation extends StatefulWidget {
  @override
  State<GravityAnimation> createState() => _GravityAnimationState();
}

class _GravityAnimationState extends State<GravityAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController.unbounded(vsync: this);

    _controller.animateWith(
      GravitySimulation(
        acceleration: 980,  // pixels/s²
        distance: 500,  // pixels to fall
        startDistance: 0,
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.translate(
          offset: Offset(0, _controller.value),
          child: child,
        );
      },
      child: const FlutterLogo(),
    );
  }
}

Scroll Physics

Bouncing Scroll

CustomScrollView(
  physics: const BouncingScrollPhysics(),
  slivers: [
    SliverList(
      delegate: SliverChildListDelegate([
        // Items
      ]),
    ),
  ],
)

Clamping Scroll

CustomScrollView(
  physics: const ClampingScrollPhysics(),
  slivers: [
    SliverList(
      delegate: SliverChildListDelegate([
        // Items
      ]),
    ),
  ],
)

Fixed Scroll

CustomScrollView(
  physics: const NeverScrollableScrollPhysics(),
  slivers: [
    SliverList(
      delegate: SliverChildListDelegate([
        // Items
      ]),
    ),
  ],
)

Platform-Adaptive Scroll

CustomScrollView(
  physics: const AlwaysScrollableScrollPhysics(),
  slivers: [
    SliverList(
      delegate: SliverChildListDelegate([
        // Items
      ]),
    ),
  ],
)

Custom Simulation

Creating Custom Simulation

class CustomSimulation extends Simulation {
  final double target;

  CustomSimulation({required this.target});

  @override
  double x(double time) {
    // Calculate position at given time
    return target * (1 - math.exp(-time / 0.5));
  }

  @override
  double dx(double time) {
    // Calculate velocity (derivative) at given time
    return target * math.exp(-time / 0.5) / 0.5;
  }

  @override
  bool isDone(double time) {
    return dx(time).abs() < 0.01;
  }
}

Usage:

_controller.animateWith(
  CustomSimulation(target: 1.0),
);

Decay Simulation

class DecaySimulation extends Simulation {
  final double velocity;

  DecaySimulation({required this.velocity});

  @override
  double x(double time) {
    return velocity * (1 - math.exp(-time));
  }

  @override
  double dx(double time) {
    return velocity * math.exp(-time);
  }

  @override
  bool isDone(double time) {
    return dx(time).abs() < 0.1;
  }
}

Combining Physics and Animation

Spring with Fallback

void animateWithSpring({
  required AnimationController controller,
  required double start,
  required double end,
}) {
  try {
    controller.animateWith(
      SpringSimulation(
        spring: const SpringDescription(
          mass: 1.0,
          stiffness: 200.0,
          damping: 15.0,
        ),
        start: start,
        end: end,
        velocity: 0.0,
      ),
    );
  } catch (e) {
    // Fallback to linear animation
    controller.animateTo(end);
  }
}

Spring with Completion Callback

_controller.animateWith(
  SpringSimulation(
    spring: const SpringDescription(
      mass: 1.0,
      stiffness: 200.0,
      damping: 15.0,
    ),
    start: 0.0,
    end: 1.0,
    velocity: 0.0,
  ),
);

_controller.addStatusListener((status) {
  if (status == AnimationStatus.completed) {
    // Animation complete
  }
});

Physics for Gestures

Swipe to Dismiss with Physics

class DismissibleWithPhysics extends StatefulWidget {
  final Widget child;
  final VoidCallback onDismiss;

  const DismissibleWithPhysics({
    super.key,
    required this.child,
    required this.onDismiss,
  });

  @override
  State<DismissibleWithPhysics> createState() => _DismissibleWithPhysicsState();
}

class _DismissibleWithPhysicsState extends State<DismissibleWithPhysics>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  double _dragX = 0;
  bool _isDismissing = false;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController.unbounded(vsync: this);
  }

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

  void _handlePanEnd(DragEndDetails details) {
    final velocity = details.velocity.pixelsPerSecond.dx;
    final screenWidth = MediaQuery.of(context).size.width;

    if (velocity.abs() > 500 || _dragX.abs() > screenWidth / 3) {
      // Dismiss
      _controller.fling(velocity: velocity / screenWidth);
      _isDismissing = true;
      _controller.addStatusListener((status) {
        if (status == AnimationStatus.completed && _isDismissing) {
          widget.onDismiss();
        }
      });
    } else {
      // Spring back
      _controller.animateWith(
        SpringSimulation(
          spring: const SpringDescription(
            mass: 1.0,
            stiffness: 300.0,
            damping: 15.0,
          ),
          start: _dragX,
          end: 0,
          velocity: velocity,
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (details) {
        if (!_controller.isAnimating) {
          setState(() {
            _dragX += details.delta.dx;
          });
        }
      },
      onPanEnd: _handlePanEnd,
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform.translate(
            offset: Offset(_dragX + _controller.value, 0),
            child: child,
          );
        },
        child: widget.child,
      ),
    );
  }
}

Performance Considerations

Simulation Complexity

Physics simulations can be computationally expensive. Optimize by:

  • Using simpler simulations when possible (e.g., linear interpolation)
  • Reducing simulation resolution
  • Caching simulation results
  • Using built-in physics widgets

Spring Tuning

Finding the right spring parameters can be trial and error:

  1. Start with moderate values (mass: 1.0, stiffness: 200, damping: 15)
  2. Adjust for feel:
    • Too slow? Increase stiffness or decrease mass
    • Too bouncy? Increase damping
    • Not bouncy enough? Decrease damping or stiffness
  3. Test on real devices

Debugging

Visualize Physics

void main() {
  timeDilation = 5.0;  // Slow down physics
  runApp(MyApp());
}

Print Simulation Values

_controller.addListener(() {
  print('Position: ${_controller.value}');
});

_controller.addStatusListener((status) {
  print('Status: $status');
});

Physics Inspector

Use Flutter DevTools to profile physics animations:

  1. Open Performance overlay
  2. Look for frame drops during physics simulations
  3. Identify expensive calculations

Best Practices

DO

  • Use physics simulations for natural-feeling motion
  • Tune spring parameters for desired feel
  • Test on various devices and screen sizes
  • Profile performance with DevTools
  • Provide fallback animations for physics failures

DON'T

  • Use complex physics when simple curves suffice
  • Forget to handle simulation completion
  • Use overly bouncy animations (distracting)
  • Ignore accessibility (respect disable animations preference)
  • Assume physics simulation completes instantly

Common Physics Patterns

Bouncy Button Press

_controller.animateWith(
  SpringSimulation(
    spring: const SpringDescription(
      mass: 0.5,
      stiffness: 500,
      damping: 10,
    ),
    start: 1.0,
    end: 0.9,
    velocity: 0,
  ),
);

Swipeable Card

_controller.fling(
  velocity: details.velocity.pixelsPerSecond.dx / 1000,
);

Pull to Refresh

RefreshIndicator(
  onRefresh: () async {
    // Refresh data
  },
  child: ListView(...),
)

Accessibility

  • Respect MediaQuery.disableAnimations setting
  • Provide non-physics alternatives
  • Ensure physics animations don't cause motion sickness
  • Test with reduced motion settings

Platform-Specific Physics

Adaptive Scroll Physics

class AdaptiveScrollPhysics extends ScrollPhysics {
  const AdaptiveScrollPhysics({super.parent});

  @override
  AdaptiveScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return AdaptiveScrollPhysics(parent: buildParent(ancestor));
  }

  @override
  SpringDescription get spring => Platform.isIOS
      ? const SpringDescription(
          mass: 0.5,
          stiffness: 100,
          damping: 10,
        )
      : const SpringDescription(
          mass: 0.8,
          stiffness: 150,
          damping: 15,
        );
}

Usage:

ListView(
  physics: const AdaptiveScrollPhysics(),
  children: [...],
)