'What's the best way to make a spinning wheel?

I'm trying to make a wheel that can spin when a user drags up and down on a screen. It's essentially an infinite vertical scroll. So far I can make it turn while actually scrolling, but I'd like to incorporate physics to make it keep spinning when you let go. At the moment I'm using a GestureDetector to put an angle into Provider, which is used to transform some child widgets that make up the wheel, like so:

GestureDetector(
  onVerticalDragUpdate: (offset) {
    provider.wheelAngle += atan(offset.delta.dy / wheelRadius);
  },
);

I'm sure I can do the physics part manually by handling the onVerticalDragEnd, but given that this is essentially just scrolling, I was wondering if it would make more sense to somehow leverage Flutter's built in scrolling stuff - maybe ScrollPhysics or one of the classes that derive from it. I don't want to reinvent the wheel (no pun intended), but I also don't want extra complexity by trying to force something else into doing what I need if it isn't a good fit. I can't quite wrap my head around ScrollPhysics, so I feel like it might be going down the route of over-complicated. Any gut feelings on what the best technique would be?



Solution 1:[1]

As pskink mentioned in the comments, animateWith is the key. In case it helps anyone in the future, here's an untested, slimmed-down version of what I ended up with. It switches between using FrictionSimulation when spinning freely and SpringSimulation when snapping to a particular angle.

class Wheel extends StatefulWidget {
  const Wheel({Key? key}) : super(key: key);

  @override
  State<Wheel> createState() => _WheelState();
}

class _WheelState extends State<Wheel> with SingleTickerProviderStateMixin {
  late AnimationController _wheelAnimationController;
  bool _isSnapping = false;
  double _radius = 0.0; // Probably set this in the constructor.
  static const double velocitySnapThreshold = 1.0;
  static const double distanceSnapThreshold = 0.25;

  @override
  Widget build(BuildContext context) {
    var provider = context.read<WheelAngleProvider>();

    _wheelAnimationController = AnimationController.unbounded(vsync: this, value: provider.wheelAngle);
    _wheelAnimationController.addListener(() {
      if (!_isSnapping) {
        // Snap to an item if not spinning quickly.
        var wheelAngle = _wheelAnimationController.value;
        var velocity = _wheelAnimationController.velocity.abs();
        var closestSnapAngle = getClosestSnapAngle(wheelAngle);
        var distance = (closestSnapAngle - wheelAngle).abs();

        if (velocity == 0 || (velocity < velocitySnapThreshold && distance < distanceSnapThreshold)) {
          snapTo(closestSnapAngle);
        }
      }
      provider.wheelAngle = _wheelAnimationController.value;
    });

    return Stack(
      children: [
        // ... <-- Visible things go here
        // Vertical dragging anywhere on the screen rotates the wheel, hence the SafeArea.
        SafeArea(
          child: GestureDetector(
            onVerticalDragDown: (details) {
              _wheelAnimationController.stop();
              _isSnapping = false;
            },
            onVerticalDragUpdate: (offset) =>
                provider.wheelAngle = provider.wheelAngle + atan(offset.delta.dy / _radius),
            onVerticalDragEnd: (details) => onRotationEnd(provider, details.primaryVelocity),
          ),
        ),
      ],
    );
  }

  double getClosestSnapAngle(double currentAngle) {
    // Do what you gotta do here.
    return 0.0;
  }

  void snapTo(double snapAngle) {
    var wheelAngle = _wheelAnimationController.value;
    _wheelAnimationController.stop();
    _isSnapping = true;
    var springSimulation = SpringSimulation(
      SpringDescription(mass: 20.0, stiffness: 10.0, damping: 1.0),
      wheelAngle,
      snapAngle,
      _wheelAnimationController.velocity,
    );
    _wheelAnimationController.animateWith(springSimulation);
  }

  void onRotationEnd(WheelAngleProvider provider, double? velocity) {
    // When velocity is not null, this is the result of a fling and it needs to spin freely.
    if (velocity != null) {
      _wheelAnimationController.stop();

      var frictionSimulation = FrictionSimulation(0.5, provider.wheelAngle, velocity / 200);
      _wheelAnimationController.animateWith(frictionSimulation);
    }
  }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Mark R