Create a wheel of fortune in Flutter

Introduction

In this article, we will show you how to easily create a wheel of fortune in Flutter. You’ll be surprised at how simple it is to make a fun wheel of fortune-like animation.

How are we going to achieve our goal?

1. First, we will create our Wheel of Fortune widget, which we will try to give a spin next.
2. Initially we will try to rotate it at some angle without animation.
3. We will animate our rotation smoothly so that it looks cool and amazing.

So, Let’s get started.

Creating our Wheel of Fortune widget

I have created the following two images which we will overlap so that it looks like a real wheel of spin. You can use the images given in this article or can easily create your own using some free tools such as Canva.

create a wheel of fortune in flutter hero image

Here we will rotate the first image and let the second arrow marker be in a fixed position.
To achieve this we will simply use a Stack widget to overlap these two images to form a Wheel of Fortune board.

                 const Stack(
                    alignment: Alignment.center,
                    fit: StackFit.loose,
                    children: [
                      Image(
                        image: AssetImage('lib/assets/wheel.png'),
                      ),
                      Image(
                        image: AssetImage('lib/assets/arrow.png'),
                      )
                    ],
                  );

Rotating our wheel of fortune widget by an angle

To rotate our widget by an angle we will use Flutter’s Transform.rotate() method. We will pass it the angle in radian with which we want to rotate our widget and then our child widget.

                Stack(
                  alignment: Alignment.center,
                  fit: StackFit.loose,
                  children: [
                    Transform.rotate(
                      angle: (90 * pi) / 180, // Converting degrees to radians
                      child: const Image(
                        image: AssetImage('lib/assets/wheel.png'),
                      ),
                    ),
                    const Image(
                      image: AssetImage('lib/assets/arrow.png'),
                    )
                  ],
                );

Now we can see our wheel to be rotated by 90 degrees. But this doesn’t look good and is so static that we are only rotating it to one angle i.e. by only one frame. How to make it a smooth animation so that the wheel looks like continuously rotating?

Let’s give a spin to our Wheel of Fortune widget

First of all flutter animations are just like our movies, where multiple frames are updated at a quick rate which gives us a feel of smooth video to our human eye. Under the hood, flutter calls the setState method continuously to update the state/frame and it can happen very fast depending on the animation properties we configure. To know more about what flutter animations really are see this official flutter video for more insights.

Till now we have achieved rotating our widget once by some angle. Now to create a smooth animation we will now rotate our same widget multiple times in a time frame so that it looks like a spin to our eyes.
To get started let’s first see the basic helpers provided by Flutter to us.

1. Animation Controller

As the name suggests it lets you controls the animation. It helps us in tasks such as starting, stopping, reversing, etc. and also let us know at what point of animation we are at. Animation controller value changes between 0 to 1, which helps us in knowing current position of animation. Animation controller uses Ticker under the hood to function. Ticker basically is an object which calls an function at every frame. We will rarely use it directly but to pass the Ticker to animation conroller Flutter provides SingleTickerProviderStateMixin. In code, we can pass the vsync param of animation controller to ‘this’ as shown in example below to give controller reference of ticker that it needs.

              class _AnimationControllerExampleState extends State
                  with SingleTickerProviderStateMixin {
                late final AnimationController animationController;
              
                @override
                void initState() {
                  animationController =
                      AnimationController(vsync: this, duration: const Duration(seconds: 4));
                  super.initState();
                }
              
                @override
                Widget build(BuildContext context) {
                  return Container();
                }
              }

2. Tween:

Tween comes from word in between. It gives us all the values between two values of the same unit. For example, if we want a container color to change from red to yellow, to create a smooth effect of transition we will have to know all the values between red to yellow and then call setState() with all these colours at all the frames so as to get a smooth transition.

Now we don’t and can’t provide all these in-between values of color, that’s where Tween comes into place. We assign a Tween to an animation controller which then knows what values to set in a frame when playing the animation. Tweens are used to create Flutter Animation objects which defines how the actual animation should look like and also attaches it to a controller. Animation object gives us capability of defining the curves for motion and also the interval within which of the duration of animation controller the current tween animation should start.


Let’s now get to the real coding part
Now to achieve our goal of spinning the wheel of fortune, we will first create our animation controller. We want the wheel to spin for 4 seconds.

  class _AnimationControllerExampleState extends State
                    with SingleTickerProviderStateMixin {
                  late final AnimationController animationController;
                
                  @override
                  void initState() {
                    animationController =
                        AnimationController(vsync: this, duration: const Duration(seconds: 4));
                    super.initState();
                  }
                
                  @override
                  Widget build(BuildContext context) {
                    return Container();
                  }
                }

Next, we will create a tween to get all the values between the angles that we want to rotate our wheel. So, inorder to rotate our wheel 10 times we will create the following tween.

                final Animation rotationTween = rotationTween = Tween(
                  begin: 0,
                  end: 10 * 360 + 72, // 10 times rotate
                );

Now we will create a animation object from our tween using the animate() method on tween and give it the controller reference and a curve of deacceleration so that it looks like wheel is slowing down at the end. Also we will provide an interval, since we want our rotation for the full duration of animation we will give interval as [0,1].

              final AnimationController animationController;
                final Animation rotationTween;

                WheelJackpotAnimation(
                    {Key? key, required this.animationController, required this.winner})
                    : rotationTween = Tween(
                        begin: 0,
                        end: 10 * 360,
                      ).animate(CurvedAnimation(
                          parent: animationController,
                          curve: const Interval(0, 1, curve: Curves.decelerate))),
                      super(key: key);

Finally, now we will use this controller and animation in our widget. To do this first we will wrap our widget that we want to spin inside a AnimatedBuilder. Then in our Transform.rotate widget we will pass angle from the tween which we can get as tween.value. And we are done now.

                AnimatedBuilder(
                  animation: animationController,
                  builder: (context, child) => Transform.rotate(
                    angle: (rotationTween.value * pi) / 180,
                    child: const Image(
                      image: AssetImage('lib/assets/wheel.png'),
                    ),
                  ),
                );
              

Bonus: Refactoring, Optimisation and make it more interactive

We can optimise our AnimatedBuilder to rebuild only the part that we want it to repaint i.e. the angle of of rotation method. So here we can keep our Image asset to not rebuild every time, to do this we can pass it as a child param as shown below. Add randomness to every time you spin.

               AnimatedBuilder(
                  animation: animationController,
                  child: const Image(
                    image: AssetImage('lib/assets/wheel.png'),
                  ),
                  builder: (context, child) => Transform.rotate(
                    angle: (rotationTween.value * pi) / 180,
                    child: child,
                  ),
                );

Next, we can keep our Tween and Wheel widget in a Stateless class instead of stateful as it doesn’t need state and only animation controller needs it.
Additionally, we can attach an event listner to our controller which can notify us about what we have won from the Wheel of fortune. We attached an event list and then when the state of animation is completed we will open a dialog box showing us the win.

So that’s it, you have learnt how to create a wheel of fortune in flutter.


Full Code:

               import 'dart:math';

                import 'package:flutter/material.dart';
                
                class WheelJackpotAnimation extends StatelessWidget {
                  final AnimationController animationController;
                  final int winner;
                  final Animation rotationTween;
                
                  WheelJackpotAnimation(
                      {Key? key, required this.animationController, required this.winner})
                      : rotationTween = Tween(
                          begin: 0,
                          end: 10 * 360 + 72 * winner.toDouble(),
                        ).animate(CurvedAnimation(
                            parent: animationController,
                            curve: const Interval(0, 1, curve: Curves.decelerate))),
                        super(key: key);
                
                  @override
                  Widget build(BuildContext context) {
                    return AnimatedBuilder(
                      animation: animationController,
                      child: const Image(
                        image: AssetImage('lib/assets/wheel.png'),
                      ),
                      builder: (context, child) => Transform.rotate(
                        angle: (rotationTween.value * pi) / 180,
                        child: child,
                      ),
                    );
                  }
                }
                
                class SpinningWheel extends StatefulWidget {
                  const SpinningWheel({Key? key}) : super(key: key);
                
                  @override
                  State createState() => _SpinningWheelState();
                }
                
                class _SpinningWheelState extends State
                    with SingleTickerProviderStateMixin {
                  late final AnimationController _controller;
                  late Animation rotationTween;
                  int winner = Random().nextInt(5);
                
                  @override
                  void initState() {
                    super.initState();
                    _controller = AnimationController(
                      duration: const Duration(milliseconds: 4000),
                      vsync: this,
                    );
                    _controller.addStatusListener((status) {
                      if (status == AnimationStatus.completed) {
                        setState(() {
                          winnerTag = "winner is $winner";
                          _scaleDialog();
                        });
                      }
                    });
                  }
                
                  Widget _dialog(BuildContext context) {
                    return AlertDialog(
                      backgroundColor: const Color(0xFF7D59E7),
                      title: const Center(
                        child: Text(
                          '🥳🎊🎊🎉',
                          style: TextStyle(
                              color: Colors.white, fontWeight: FontWeight.w800, fontSize: 20),
                        ),
                      ),
                      content: Text(
                        youWonFunction(),
                        style: const TextStyle(
                            color: Colors.white, fontWeight: FontWeight.w800, fontSize: 20),
                      ),
                    );
                  }
                
                  String youWonFunction() {
                    if (winner == 0) return 'You won Tesla Model 5';
                    if (winner == 1) return 'You have to goto hell!!';
                    if (winner == 2) return 'You won Ducati Scrambler';
                    if (winner == 3) return 'You have to goto hell!!';
                    return 'You won 1 Million Dollars';
                  }
                
                  void _scaleDialog() {
                    showGeneralDialog(
                      context: context,
                      barrierDismissible: true,
                      barrierLabel: 'Card',
                      pageBuilder: (ctx, a1, a2) {
                        return Container();
                      },
                      transitionBuilder: (ctx, a1, a2, child) {
                        var curve = Curves.easeInOut.transform(a1.value);
                        return Transform.scale(
                          scale: curve,
                          child: _dialog(ctx),
                        );
                      },
                      transitionDuration: const Duration(milliseconds: 300),
                    );
                  }
                
                  @override
                  void dispose() {
                    _controller.dispose();
                    super.dispose();
                  }
                
                  String winnerTag = "";
                
                  @override
                  Widget build(BuildContext context) {
                    final MediaQueryData mediaQueryData = MediaQuery.of(context);
                    return Scaffold(
                      backgroundColor: const Color(0xFF7D59E7),
                      body: SizedBox(
                        height: mediaQueryData.size.height,
                        width: mediaQueryData.size.width,
                        child: Stack(
                          children: [
                            SizedBox(
                                height: mediaQueryData.size.height,
                                width: mediaQueryData.size.width,
                                child: const GridPaper()),
                            Center(
                              child: Column(
                                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                                crossAxisAlignment: CrossAxisAlignment.center,
                                mainAxisSize: MainAxisSize.max,
                                children: [
                                  Stack(
                                    fit: StackFit.loose,
                                    alignment: Alignment.center,
                                    children: [
                                      WheelJackpotAnimation(
                                        animationController: _controller,
                                        winner: winner,
                                      ),
                                      const Image(
                                        image: AssetImage('lib/assets/arrow.png'),
                                      )
                                    ],
                                  ),
                                  InkWell(
                                    onTap: () {
                                      if (_controller.isAnimating) return;
                                      setState(() {
                                        winner = Random().nextInt(5);
                                        winnerTag = "";
                                      });
                                      _controller.reset();
                                      _controller.forward().orCancel;
                                    },
                                    child: ElevatedButton(
                                      onPressed: () {
                                        if (_controller.isAnimating) return;
                                        setState(() {
                                          winner = Random().nextInt(5);
                                          winnerTag = "";
                                        });
                                        _controller.reset();
                                        _controller.forward().orCancel;
                                      },
                                      style: ButtonStyle(
                                          padding: MaterialStateProperty.all(
                                              const EdgeInsets.symmetric(
                                                  vertical: 16, horizontal: 100)),
                                          backgroundColor: MaterialStateColor.resolveWith(
                                            (states) => _controller.isAnimating
                                                ? const Color(0xFF5A5A5A)
                                                : const Color(0xFFD6FD17),
                                          ),
                                          shape:
                                              MaterialStateProperty.all(
                                            const RoundedRectangleBorder(
                                              borderRadius: BorderRadius.all(
                                                Radius.circular(0),
                                              ),
                                            ),
                                          )),
                                      child: const Text(
                                        'Spin',
                                        style: TextStyle(
                                            color: Colors.black,
                                            fontWeight: FontWeight.w800,
                                            fontSize: 20),
                                      ),
                                    ),
                                  )
                                ],
                              ),
                            ),
                          ],
                        ),
                      ),
                    );
                  }
                }                
              

Github Code Repo : flutter-animations

You can find more blogs here.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *