davidanaya.io

Spinning the wheel in Flutter

April 22, 2019 | 26 Minute Read

header

In this article, I will show how to use animations in Flutter by building a very simple yet powerful widget that can be used to create really fun interactions with the user.

Spin the wheel!

A few years ago I had to implement a spinning wheel for a web application and, well… I had a hard time with it, so I thought that I would try to do the same in Flutter. A couple of days later I was ready to publish it as a library https://pub.dartlang.org/packages/flutter_spinning_wheel. You can see below an example of what you can do with it.

spinning-wheel

Men at work

The requirements that I set myself for this development were the following ones:

  • I could use any png image as a wheel instead of dynamically creating one with a canvas. That way I could easily change the look of it, but I will still need to know what option is being selected as the wheel spins.
  • The wheel will be interactive; that is, the user can at least start the wheel by taping or dragging.
  • The speed at which the wheel spins will be determined by how fast the user was dragging when the finger stopped contacting the screen.
  • Once spinning, the wheel will decelerate at a constant rate.
  • There will be at least a callback function to inform the user when the wheel stops about the selected (winner) option.

It seems like a lot of work, but it takes less than 200 lines of code to implement it, which I think it’s very impressive.

Short stories

We can separate the work here in two different areas:

  • Animation. We want to take an image and rotate it during a certain amount of time.
  • Interaction. Once we know how to animate the wheel we will see how to trigger the animation; that is, using gestures.

In the final implementation there’s a bit more stuff going on with configuration parameters and fine-tuning, so feel free to check it out in the Github repo. Here though, I’ll try to keep things simple and go through each step in order.

Animate

The first thing we need to do is animate the wheel. Let’s look at the complete code and then break it down.

import 'dart:math';

import 'package:flutter/material.dart';

class SpinningWheel extends StatefulWidget {
  final double width;
  final double height;
  final Image image;

  SpinningWheel(this.image, {@required this.width, @required this.height});

  @override
  _SpinningWheelState createState() => _SpinningWheelState();
}

class _SpinningWheelState extends State<SpinningWheel>
    with SingleTickerProviderStateMixin {
  AnimationController _animationController;
  Animation<double> _animation;

  @override
  void initState() {
    super.initState();

    _animationController = new AnimationController(
      vsync: this,
      duration: Duration(seconds: 5),
    );
    _animation = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(parent: _animationController, curve: Curves.linear));
  }

  _startOrStop() {
    print(
        'start/stop ${_animationController.status} - ${_animationController.isAnimating}');
    if (_animationController.isAnimating) {
      _animationController.stop();
    } else {
      _animationController.reset();
      _animationController.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        height: widget.height,
        width: widget.width,
        child: AnimatedBuilder(
            animation: _animation,
            child: Container(child: widget.image),
            builder: (context, child) {
              return Transform.rotate(
                angle: _animation.value,
                child: child,
              );
            }),
      ),
      SizedBox(height: 30),
      RaisedButton(
        child: Text('Start/Stop'),
        onPressed: _startOrStop,
      )
    ]);
  }
}

Animate what?

First thing, drawing the wheel, so we need at least the image and the dimensions, and as we want this to be configurable we will have them passed in through the constructor.

What is an animation, really?

An animation is basically a change of state over time for a particular element (a Widget in this case), so we obviously will need a StatefulWidget to keep a state, but we also need something that triggers a change of that state, and in Flutter that’s a Ticker.

class _SpinningWheelState extends State<SpinningWheel>
    with SingleTickerProviderStateMixin {
  AnimationController _animationController;
  Animation<double> _animation;

A Ticker will fire many times per second (up to 120 if supported) allowing us to update our state and thus rebuild our widget. By “extending” the mixin SingleTickerProviderStateMixin we are basically using our own class as a TickerProvider. We will then update our state with Animation and AnimationController.

@override
  void initState() {
    super.initState();

    _animationController = new AnimationController(
      vsync: this,
      duration: Duration(seconds: 5),
    );
    _animation = Tween(begin: 0.0, end: 2 * pi).animate(
        CurvedAnimation(parent: _animationController, curve: Curves.linear));
  }

The AnimationController will configure our widget as a TickerProvider with the vsync parameter and also the duration of the animation, which in this case I set to 5 seconds, but later it will depend on the speed of the spin.

As I said before, an animation is basically a change in the state. In our case, we can make the wheel spin if we just change the rotation angle, so that’s the value we will need to modify to create our animation. Animation lets us do that by using the animate method from Tween, which will interpolate a value on each tick from 0.0 to 2*pi (a complete spin goes from 0 to 2pi radians). I’m also using a linear curve animation because I want the wheel to spin at the same speed during the whole animation so the interpolation will be linear.

So what I’m getting with this configuration is a linear interpolation from 0.0 to 2*pi during 5 seconds. That is, the wheel will do exactly one full spin that will take 5 seconds to complete.

Rebuild on tick

We could use setState() to update our state and rebuild, but things are much easier thanks to a special widget by Flutter called AnimatedBuilder.

@override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        height: widget.height,
        width: widget.width,
        child: AnimatedBuilder(
            animation: _animation,
            child: Container(child: widget.image),
            builder: (context, child) {
              return Transform.rotate(
                angle: _animation.value,
                child: child,
              );
            }),
      ),
      ...
    ]);
  }

We can use AnimateBuilder the same way we do with StreamBuilder or FutureBuilder but, in this case, the rebuild will be triggered by our TickerProvider which is served through an Animation. Then the builder() function should return the widget we want to display. Obviously, we want this widget to change with the interpolated value from the Tween, so we can use another handy widget from Flutter, Transform.rotate(), that will render a widget (an Image in our case) with a specific rotation angle.

3, 2, 1… start!

Our animation is ready, but nothing happens. Fear not, we still need to start it so, for now, we can use a temporary button to interact with it.

_startOrStop() {
    if (_animationController.isAnimating) {
      _animationController.stop();
    } else {
      _animationController.reset();
      _animationController.forward();
    }
  }

...

  RaisedButton(
        child: Text('Start/Stop'),
        onPressed: _startOrStop,
      )

Every time we press the button we will start the animation or stop it depending on the current status.

start-stop

It’s all gestures

Time to add real user interaction using gestures. For that, I’m going to simplify a little bit our scenario and assume that once the wheel is spinning the user won’t be allowed to stop it. So these are the use cases I want to cover:

  • when the user taps down and drags the pointer (finger) the wheel will spin with it.
  • as soon as the user stops dragging, the animation will start with a certain speed and direction (clockwise or anti-clockwise).

So drag me, baby

We did something very similar to this in a previous article with our circular-slider and this is much simpler because we don’t have to deal with canvas but we still need to do some operations to transform our drag coordinates to radians to calculate the rotation. I created a class SpinVelocity that abstract a few utility methods, so feel free to look for the implementation in the Github repo as here I will just use them and assume it’s working as I expect.

  void _moveWheel(DragUpdateDetails details) {
    if (_animationController.isAnimating) return;

    var localOffset = _updateLocalPosition(details.globalPosition);
    var angle = _spinVelocity.offsetToRadians(localOffset);

    setState(() {
      _currentDistance = angle;
    });
  }

  Offset _updateLocalPosition(Offset position) {
    if (_renderBox == null) {
      _renderBox = context.findRenderObject();
    }
    return _renderBox.globalToLocal(position);
  }

Method _updateLocalPosition() will transform a global (in the screen) position for the pointer to a local one (in the wheel), while _moveWheel() will calculate the new angle by transforming the previous offset into a radians value between 0 and 2*pi and rebuilding the widget.

Note that there is no animation here, this is a regular rebuild from a StatefulWidget. Now we just need to temporarily break our animation code and build the image with a new rotation angle.

@override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        height: widget.height,
        width: widget.width,
        child: GestureDetector(
          onPanUpdate: _moveWheel,
          child: AnimatedBuilder(
              animation: _animation,
              child: Container(child: widget.image),
              builder: (context, child) {
                return Transform.rotate(
                  angle: _currentDistance,
                  child: child,
                );
              }),
        ),
      ),
      SizedBox(height: 30),
      RaisedButton(
        child: Text('Start/Stop'),
        onPressed: _startOrStop,
      )
    ]);
  }

drag-me-baby

Spinning around

Time to integrate the animation into our mix. We want to start the animation when the user drag movement ends and the pointer stops contacting the screen. Before jumping into the code there are a few things we need to consider:

  • The velocity for the wheel is not constant and the spin will probably exceed one full rotation, so our Tween can no longer go from 0.0 to 2*pi radians; instead, if I know the initial circular speed of the wheel and I establish a fixed deceleration I can calculate how long the spin is going to last. I will use now an interpolation from 0.0 to 1.0, being 1.0 the total duration of the animation.
  • Now the animation will probably not start from a rotation angle of value 0 as the user might have been dragging the pointer and rotating the wheel so we will need to consider an initial rotation angle when calculating the final position of the wheel.
  • I created another utility class NonUniformCircularMotion that will take care of calculating speeds and decelerations, again feel free to check the source code but I will assume everything works as I expect.

First, let’s add a new listener to the wheel for onPanEnd that will execute the following method startAnimation().

void _startAnimation(DragEndDetails details) {
    if (_animationController.isAnimating) return;

    var velocity = _spinVelocity.getVelocity(
        _localPosition, details.velocity.pixelsPerSecond);

    _localPosition = null;
    _isBackwards = velocity < 0;
    _initialCircularVelocity = pixelsPerSecondToRadians(velocity.abs());
    _totalDuration = _motion.duration(_initialCircularVelocity);

    _animationController.duration =
        Duration(milliseconds: (_totalDuration * 1000).round());

    _animationController.reset();
    _animationController.forward();
  }

Here I calculate the velocity for the wheel based on the pixelsPerSecond value that Flutter graciously provides for the end of the drag movement. This velocity will be negative for anti-clockwise rotation, and then I can also calculate the total duration for the whole spin, update my AnimationController with it and start the animation calling the forward() method.

Once I trigger the start of the animation, AnimatedBuilder will be executed.

child: GestureDetector(
          onPanUpdate: _moveWheel,
          onPanEnd: _startAnimation,
          child: AnimatedBuilder(
              animation: _animation,
              child: Container(child: widget.image),
              builder: (context, child) {
                _updateAnimationValues();
                return Transform.rotate(
                  angle: _initialSpinAngle + _currentDistance,
                  child: child,
                );
              }),
        ),

As you can see I squeezed another function in there that will be updating the values that the builder() function depends on. Let’s see it:

  void _updateAnimationValues() {
    if (_animationController.isAnimating) {
      var currentTime = _totalDuration * _animation.value;
      _currentDistance =
          _motion.distance(_initialCircularVelocity, currentTime);
      if (_isBackwards) {
        _currentDistance = -_currentDistance;
      }
    }

    var modulo = _motion.modulo(_currentDistance + _initialSpinAngle);
    if (!_animationController.isAnimating) {
      _initialSpinAngle = modulo;
      _currentDistance = 0;
    }
  }

On the first condition in updateAnimationValues() I update the current distance (again in radians). Calculating the current time first using the _animation.value interpolation value and using an utility function from NonUniformCircularMotion then to calculate the total distance covered during the animation, positive for clockwise and negative otherwise.

Finally, if the spin is over (the animation is finished) I want to set the distance back to 0 and calculate the initial spin angle for the next time the animation is triggered, but to simplify things I use a modulo function to limit the values from 0.0 to 2*pi again.

spinning-around

All we need is love

Everybody deserves some love, even our wheel which, at the moment is a bit useless. Let’s add a couple of new features that will make a difference.

We have a winner

How do we know who won? Well, let’s add a simple feature so that the user can also provide a secondary image that will be rendered on top and will remain static during the animation.

  SpinningWheel(
    this.image, {
    @required this.width,
    @required this.height,
    this.secondaryImage,
    this.secondaryImageHeight,
    this.secondaryImageWidth,
  });

We just need the image and the dimensions, and we will make sure it remains fixed and centered in the wheel by using a Stack widget and some minor calculations.

  double get topSecondaryImage =>
      (widget.height / 2) - (widget.secondaryImageHeight / 2);

  double get leftSecondaryImage =>
      (widget.width / 2) - (widget.secondaryImageWidth / 2);

  double get widthSecondaryImage => widget.secondaryImageWidth ?? widget.width;

  double get heightSecondaryImage =>
      widget.secondaryImageHeight ?? widget.height;

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        height: widget.height,
        width: widget.width,
        child: Stack(
          children: <Widget>[
            GestureDetector(
              ...
            ),
            widget.secondaryImage != null
                ? Positioned(
                    top: topSecondaryImage,
                    left: leftSecondaryImage,
                    child: Container(
                      height: heightSecondaryImage,
                      width: widthSecondaryImage,
                      child: widget.secondaryImage,
                    ))
                : Container(),
          ],
        ),
      ),
    ]);
  }

we-have-a-winner

But really, who is the winner

We got a visual indicator now for the winner, but we want to make sure we can react to it in our code, so let’s just add some extra parameters to deal with it.

First, we need to know the divisions in our wheel, and since we can use any png to do that, we can not infer that value, it needs to be provided. Of course, all divisions have to be equal in angle.

Again we will use a method anglePerDivision() from our NonUniformCircularMotion class to calculate the angle for each division inside the initState() function, as this value will remain constant through the animation.

    _dividerAngle = _motion.anglePerDivision(widget.dividers);

Now we just need to calculate the currentDivider every time the wheel status changes.

  void _updateAnimationValues() {
    if (_animationController.isAnimating) {
      var currentTime = _totalDuration * _animation.value;
      _currentDistance =
          _motion.distance(_initialCircularVelocity, currentTime);
      if (_isBackwards) {
        _currentDistance = -_currentDistance;
      }
    }

    var modulo = _motion.modulo(_currentDistance + _initialSpinAngle);
    _currentDivider = widget.dividers - (modulo ~/ _dividerAngle);

    if (!_animationController.isAnimating) {
      _initialSpinAngle = modulo;
      _currentDistance = 0;
    }
  }

With modulo representing the current rotation angle for the wheel and knowing the angle per division we have all we need.

Now, our last step will be informing the outside world about it, so we can use a callback from our constructor and execute it right after updateAnimationValues().

GestureDetector(
              onPanUpdate: _moveWheel,
              onPanEnd: _startAnimation,
              child: AnimatedBuilder(
                  animation: _animation,
                  child: Container(child: widget.image),
                  builder: (context, child) {
                    _updateAnimationValues();
                    widget.onUpdate(_currentDivider);
                    return Transform.rotate(
                      angle: _initialSpinAngle + _currentDistance,
                      child: child,
                    );
                  }),
            ),

That’s all folks

Some recap about what we did:

  • Display an image with specific dimensions and make it interactive. This is easily done with GestureDetector and a standard StatefulWidget, plus a Transform.rotate widget.
  • Use GestureDetector to detect the end of the drag movement and calculate the velocity and duration of the animation.
  • Animate the wheel from an initial position all the way to the end using AnimationBuilder to rebuild the widget on every tick of the animation.
  • Connect it to the world with a simple callback function.

That’s not 200 lines code!

You’re right, it’s even less. Here you have the complete code.

import 'package:flutter/material.dart';
import 'package:flutter_spinning_wheel/src/utils.dart';

class SpinningWheel extends StatefulWidget {
  final double width;
  final double height;
  final Image image;
  final int dividers;
  final Image secondaryImage;
  final double secondaryImageHeight;
  final double secondaryImageWidth;
  final Function onUpdate;

  SpinningWheel(
    this.image, {
    @required this.width,
    @required this.height,
    @required this.dividers,
    this.secondaryImage,
    this.secondaryImageHeight,
    this.secondaryImageWidth,
    this.onUpdate,
  });

  @override
  _SpinningWheelState createState() => _SpinningWheelState();
}

class _SpinningWheelState extends State<SpinningWheel>
    with SingleTickerProviderStateMixin {
  AnimationController _animationController;
  Animation<double> _animation;

  SpinVelocity _spinVelocity;
  NonUniformCircularMotion _motion;

  RenderBox _renderBox;
  double _currentDistance = 0;
  Offset _localPosition;
  bool _isBackwards;
  double _totalDuration = 0;
  double _initialCircularVelocity = 0;
  double _initialSpinAngle = 0;
  double _dividerAngle;
  int _currentDivider;

  @override
  void initState() {
    super.initState();

    _spinVelocity = SpinVelocity(width: widget.width, height: widget.height);
    _motion = NonUniformCircularMotion(resistance: 0.5);
    _dividerAngle = _motion.anglePerDivision(widget.dividers);

    _animationController = new AnimationController(
      vsync: this,
      duration: Duration(seconds: 5),
    );
    _animation = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(parent: _animationController, curve: Curves.linear));
  }

  double get topSecondaryImage =>
      (widget.height / 2) - (widget.secondaryImageHeight / 2);

  double get leftSecondaryImage =>
      (widget.width / 2) - (widget.secondaryImageWidth / 2);

  double get widthSecondaryImage => widget.secondaryImageWidth ?? widget.width;

  double get heightSecondaryImage =>
      widget.secondaryImageHeight ?? widget.height;

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        height: widget.height,
        width: widget.width,
        child: Stack(
          children: <Widget>[
            GestureDetector(
              onPanUpdate: _moveWheel,
              onPanEnd: _startAnimation,
              child: AnimatedBuilder(
                  animation: _animation,
                  child: Container(child: widget.image),
                  builder: (context, child) {
                    _updateAnimationValues();
                    widget.onUpdate(_currentDivider);
                    return Transform.rotate(
                      angle: _initialSpinAngle + _currentDistance,
                      child: child,
                    );
                  }),
            ),
            widget.secondaryImage != null
                ? Positioned(
                    top: topSecondaryImage,
                    left: leftSecondaryImage,
                    child: Container(
                      height: heightSecondaryImage,
                      width: widthSecondaryImage,
                      child: widget.secondaryImage,
                    ))
                : Container(),
          ],
        ),
      ),
    ]);
  }

  void _updateAnimationValues() {
    if (_animationController.isAnimating) {
      var currentTime = _totalDuration * _animation.value;
      _currentDistance =
          _motion.distance(_initialCircularVelocity, currentTime);
      if (_isBackwards) {
        _currentDistance = -_currentDistance;
      }
    }

    var modulo = _motion.modulo(_currentDistance + _initialSpinAngle);
    _currentDivider = widget.dividers - (modulo ~/ _dividerAngle);

    if (!_animationController.isAnimating) {
      _initialSpinAngle = modulo;
      _currentDistance = 0;
    }
  }

  void _moveWheel(DragUpdateDetails details) {
    if (_animationController.isAnimating) return;

    _localPosition = _updateLocalPosition(details.globalPosition);
    var angle = _spinVelocity.offsetToRadians(_localPosition);

    setState(() {
      _initialSpinAngle = angle;
    });
  }

  Offset _updateLocalPosition(Offset position) {
    if (_renderBox == null) {
      _renderBox = context.findRenderObject();
    }
    return _renderBox.globalToLocal(position);
  }

  void _startAnimation(DragEndDetails details) {
    if (_animationController.isAnimating) return;

    var velocity = _spinVelocity.getVelocity(
        _localPosition, details.velocity.pixelsPerSecond);

    _localPosition = null;
    _isBackwards = velocity < 0;
    _initialCircularVelocity = pixelsPerSecondToRadians(velocity.abs());
    _totalDuration = _motion.duration(_initialCircularVelocity);

    _animationController.duration =
        Duration(milliseconds: (_totalDuration * 1000).round());

    _animationController.reset();
    _animationController.forward();
  }
}

You can check the final version in https://github.com/davidanaya/flutter-spinning-wheel where I added some extra configuration options. Thanks for reading!