Cool Flutter packages: flutter_physics

3 minute read

Last week I discovered a pretty fun little animations package built by @sangddn that lets you replace classic Flutter animation controller with one that follows spring-based simulations: flutter_physics.

The concept of simulation is present in Flutter, but I’ve always found it a bit tricky to implement. I tried it few times, but eventually would switch to either custom calculations or basic curves.

Normally when building this kind of pseudo-3d movement you wouldn’t use typical tweens and curves, as they lack inertia. With flutter_physics you can follow the same approach as with any animation, but just replace the controller with PhysicsController or TweenAnimationBuilder with PhysicsBuilder. In the example above the card starts to move more naturally and just feels better to the eye and finger.

One area where I feel the physics-based animation would be beneficial is bottom sheet movement. I think most of the implementations, including the Flutter bottom sheets, don’t feel as nice as some of the provided by iOS. I’d love to change it and will experimented with using flutter_physics for sheets animations.

Here’s the behavior with ordinary Flutter AnimationController:

And here after just replacing the controller with PhysicsController (via adapter - source code below):

If you want to try something refreshing, play with the flutter_physics and maybe share some feedback about how you approach animations with John Ryan here on the Flutter Forum.


Source code

import 'package:flutter/material.dart';
import 'package:flutter_physics/flutter_physics.dart';

class AnimationsControllerAdapter implements AnimationController {
  AnimationsControllerAdapter(this.physicsController);

  final PhysicsController physicsController;

  @override
  Duration? get duration => physicsController.duration;

  @override
  set duration(Duration? value) {
    physicsController.duration = value;
  }

  @override
  Duration? get reverseDuration => physicsController.reverseDuration;

  @override
  set reverseDuration(Duration? value) {
    physicsController.reverseDuration = value;
  }

  @override
  double get value => physicsController.value;

  @override
  set value(double value) {
    physicsController.value = value;
  }

  @override
  void addListener(VoidCallback listener) {
    physicsController.addListener(listener);
  }

  @override
  void addStatusListener(AnimationStatusListener listener) {
    physicsController.addStatusListener(listener);
  }

  @override
  TickerFuture animateBack(double target,
      {Duration? duration, Curve curve = Curves.linear}) {
    return physicsController.animateTo(
      target,
      duration: duration,
      physics: curve,
    );
  }

  @override
  TickerFuture animateTo(double target,
      {Duration? duration, Curve curve = Curves.linear}) {
    return physicsController.animateTo(
      target,
      duration: duration,
      physics: curve,
    );
  }

  @override
  TickerFuture animateWith(Simulation simulation) {
    return physicsController.animateWith(simulation);
  }

  @override
  AnimationBehavior get animationBehavior =>
      physicsController.animationBehavior;

  @override
  void clearListeners() {
    physicsController.clearListeners();
  }

  @override
  void clearStatusListeners() {
    physicsController.clearStatusListeners();
  }

  @override
  String? get debugLabel => physicsController.debugLabel;

  @override
  void didRegisterListener() {
    // TODO: implement didRegisterListener
  }

  @override
  void didUnregisterListener() {
    physicsController.didUnregisterListener();
  }

  @override
  void dispose() {
    physicsController.dispose();
  }

  @override
  Animation<U> drive<U>(Animatable<U> child) {
    return physicsController.drive(child);
  }

  @override
  TickerFuture fling(
      {double velocity = 1.0,
      SpringDescription? springDescription,
      AnimationBehavior? animationBehavior}) {
    return physicsController.fling(
      velocity: velocity,
      springDescription: springDescription,
      animationBehavior: animationBehavior,
    );
  }

  @override
  TickerFuture forward({double? from}) {
    return physicsController.forward(from: from);
  }

  @override
  bool get isAnimating => physicsController.isAnimating;

  @override
  bool get isCompleted => physicsController.isCompleted;

  @override
  bool get isDismissed => physicsController.isDismissed;

  @override
  bool get isForwardOrCompleted => physicsController.isForwardOrCompleted;

  @override
  Duration? get lastElapsedDuration => physicsController.lastElapsedDuration;

  @override
  double get lowerBound => physicsController.lowerBound;

  @override
  void notifyListeners() {
    physicsController.notifyListeners();
  }

  @override
  void notifyStatusListeners(AnimationStatus status) {
    physicsController.notifyStatusListeners(status);
  }

  @override
  void removeListener(VoidCallback listener) {
    physicsController.removeListener(listener);
  }

  @override
  void removeStatusListener(AnimationStatusListener listener) {
    physicsController.removeStatusListener(listener);
  }

  @override
  TickerFuture repeat(
      {double? min,
      double? max,
      bool reverse = false,
      Duration? period,
      int? count}) {
    return physicsController.repeat(
      min: min,
      max: max,
      reverse: reverse,
      period: period,
      count: count,
    );
  }

  @override
  void reset() {
    physicsController.reset();
  }

  @override
  void resync(TickerProvider vsync) {
    physicsController.resync(vsync);
  }

  @override
  TickerFuture reverse({double? from}) {
    return physicsController.reverse(from: from);
  }

  @override
  AnimationStatus get status => physicsController.status;

  @override
  void stop({bool canceled = true}) {
    physicsController.stop(canceled: canceled);
  }

  @override
  String toStringDetails() {
    return physicsController.toStringDetails();
  }

  @override
  TickerFuture toggle({double? from}) {
    throw UnimplementedError('toggle is not implemented yet');
  }

  @override
  double get upperBound => physicsController.upperBound;

  @override
  double get velocity => physicsController.velocity;

  @override
  Animation<double> get view => physicsController.view;
}

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

  @override
  State<BottomSheetPage> createState() => _BottomSheetPageState();
}

class _BottomSheetPageState extends State<BottomSheetPage>
    with TickerProviderStateMixin {
  late PhysicsController controller;

  @override
  void initState() {
    controller = PhysicsController(vsync: this);
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            showModalBottomSheet(
              context: context,
              transitionAnimationController:
                  AnimationsControllerAdapter(controller),
              builder: (context) {
                return Container(
                  height: 200,
                  color: Colors.blueGrey,
                  child: Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      mainAxisSize: MainAxisSize.min,
                      children: <Widget>[
                        const Text('Modal BottomSheet'),
                        ElevatedButton(
                          child: const Text('Close BottomSheet'),
                          onPressed: () => Navigator.pop(context),
                        ),
                      ],
                    ),
                  ),
                );
              },
            );
          },
          child: Text('Show bottom sheet'),
        ),
      ),
    );
  }
}

Updated: