Animated Circular FAB Menu with Flutter

This Article is posted by retroportalstudio at 3/31/2020 9:11:42 AM



One of the most appealing thing about Flutter, which attracts most beginners and developers in general, is that it allows to create complex animations with ease. Especially in case of Android, where you would have to scratch your head hard, constantly switching between XML Layouts and Native Java Code, to create complex fluid animations, Flutter lets you do the same thing with just a few lines of code 💙.

In this read, We will be creating a Circular Animated Menu, which will pop open in a nice and fluid way, when the Floating Action Button is clicked and reverts back the same way. You can see the target example, down here…

Here, we will not be using the “floatingActionButton” property of Scaffold, to add Floating Action Button to our app. Instead we will be creating a Custom Stateful Widget called CircularButton and will which looks something like this…
class CircularButton extends StatelessWidget {

  final double width;
  final double height;
  final Color color;
  final Icon icon;
  final Function onClick;

  CircularButton({this.color, this.width, this.height, this.icon, this.onClick});


  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(color: color,shape: BoxShape.circle),
      width: width,
      height: height,
      child: IconButton(icon: icon,enableFeedback: true, onPressed: onClick),
    );
  }
}
We need to position this at the bottom-right corner of Screen using Stack and Positioned Widget. Now, as we don’t need only a single button, we won’t directly be adding this CircularButton as a child to Positioned. 
Insted, we will add a Stack to Positioned widget , so that all the CircularButtons are overlapped in Stack. At this point Screen Widget (MyHomePage) will look something like this:
class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State with SingleTickerProviderStateMixin {
  

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      body: Container(
        width: size.width,
        height: size.height,
        child: Stack(
          children: [
            Positioned(
              right: 30,
              bottom: 30,
              child: Stack(

                children: [
                  CircularButton(
                    color: Colors.blue,
                    width: 50,
                    height: 50,
                    icon: Icon(
                      Icons.add,
                      color: Colors.white,
                    ),
                  ),
                  CircularButton(
                    color: Colors.black,
                    width: 50,
                    height: 50,
                    icon: Icon(
                      Icons.camera_alt,
                      color: Colors.white,
                    ),
                  ),
                  CircularButton(
                    color: Colors.orangeAccent,
                    width: 50,
                    height: 50,
                    icon: Icon(
                      Icons.person,
                      color: Colors.white,
                    ),
                  ),

                  CircularButton(
                    color: Colors.red,
                    width: 60,
                    height: 60,
                    icon: Icon(
                      Icons.menu,
                      color: Colors.white,
                    ),
                    onClick: () {
                          // Trigger Animation Here
                    },
                  ),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}

And our App will look something like the screen on the right side, in the image below:

In the screen on right side of above Image, all the 3 buttons are stacked behind the main button. And, we want all these buttons to translate/move at the angles shown in the screen on left side of Above Image. For this, we will have to wrap each and of the 3 buttons, except the Main Button with a Transform.translate()
This widget requires an offset property for which we will be using “Offset.fromDirection(direction, distance)” . This will allow us to move the child widget to the respective Transform in a particular direction at a given angle given in Radians. You can easily convert degrees to radians by using the function:
double getRadiansFromDegree(double degree) {
    double unitRadian = 57.295779513;
    return degree / unitRadian;
  }
We will be using this function frequently…. Now, other than translation, we also want to scale and rotate our buttons, as they move out of the main button. and scale down as they move back in. For this we will have to wrap the all the 3 buttons with a basic Transform widget where we will be passing in the Matrix4 with rotation and scale values.
We will also be wrapping the Main Floating Action Button with Transform but unlike other 3 buttons, we will only be passing a “Matrix4.rotationZ ()” to it as a value of transform property. At this point our build function will look something like this:
@override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      body: Container(
        width: size.width,
        height: size.height,
        child: Stack(
          children: [
            Positioned(
              right: 30,
              bottom: 30,
              child: Stack(

                children: [
                  Transform.translate(
                    offset: Offset.fromDirection(getRadiansFromDegree(270), degOneTranslationAnimation.value * 100),
                    child: Transform(
                      transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value))..scale(degOneTranslationAnimation.value),
                      alignment: Alignment.center,
                      child: CircularButton(
                        color: Colors.blue,
                        width: 50,
                        height: 50,
                        icon: Icon(
                          Icons.add,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                  Transform.translate(
                    offset:
                    Offset.fromDirection(getRadiansFromDegree(225), degOneTranslationAnimation.value * 100),
                    child: Transform(
                      transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value))..scale(degOneTranslationAnimation.value),
                      alignment: Alignment.center,
                      child: CircularButton(
                        color: Colors.black,
                        width: 50,
                        height: 50,
                        icon: Icon(
                          Icons.camera_alt,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                  Transform.translate(
                    offset:
                        Offset.fromDirection(getRadiansFromDegree(180), degOneTranslationAnimation.value * 100),
                    child: Transform(
                      transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value))..scale(degOneTranslationAnimation.value),
                      alignment: Alignment.center,
                      child: CircularButton(
                        color: Colors.orangeAccent,
                        width: 50,
                        height: 50,
                        icon: Icon(
                          Icons.person,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),

                  Transform(
                    transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value)),
                    alignment: Alignment.center,
                    child: CircularButton(
                      color: Colors.red,
                      width: 60,
                      height: 60,
                      icon: Icon(
                        Icons.menu,
                        color: Colors.white,
                      ),
                      onClick: () {
                        if (animationController.isCompleted) {
                          animationController.reverse();
                        } else {
                          animationController.forward();
                        }
                      },
                    ),
                  ),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
In this code, you can see that we have passed “degOneTranslationAnimation” (Animation Object) to pass value to Transform.translate and scale of Matrix4. This will help us animate the value from 0.0 to 100 for translate and 0.0 to 1.0 for scale. We also have to initialize the Animation and related AnimationController as below:
AnimationController animationController;
  Animation degOneTranslationAnimation, degTwoTranslationAnimation, degThreeTranslationAnimation;
  Animation rotationAnimation;

  @override
  void initState() {
    animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 250));
    degOneTranslationAnimation = Tween(begin: 0.0, end: 1.0).animate(animationController);
        rotationAnimation = Tween(begin: 180.0, end: 0.0)
        .animate(CurvedAnimation(parent: animationController, curve: Curves.easeOut));
    super.initState();
    animationController.addListener(() {
      setState(() {});
    });
  }

  @override
  void dispose() {
    animationController.dispose();
    super.dispose();
  }
Here, we are using the Tween from 0.0 to 1.0 , we will be using its raw value for Scale , but for Translation we will have to multiply this by 100 or any other relevant factor to get the translation value.
At this point, if you run the app, the results will look something like:
This looks cool,no doubt but it lacks the fluidness which we have in our target animation. To add fluidity to this animation we will have to create a separate animation instance for all the three menu buttons.
The required fluidity cannot be achieved by a simple tween so insted , we will have to use “TweenSequence”. TweenSequence is easy to use, and we need to pass a list of TweenSequenceItems to it. Our new Animation Initializations will look something like the code below:
degOneTranslationAnimation = TweenSequence([
      TweenSequenceItem(tween: Tween(begin: 0.0,end: 1.2), weight: 75.0),
      TweenSequenceItem(tween: Tween(begin: 1.2,end: 1.0), weight: 25.0),
    ])
        .animate(animationController);
    degTwoTranslationAnimation = TweenSequence([
      TweenSequenceItem(tween: Tween(begin: 0.0,end: 1.4), weight: 55.0),
      TweenSequenceItem(tween: Tween(begin: 1.4,end: 1.0), weight: 45.0)
    ])
        .animate(animationController);
    degThreeTranslationAnimation = TweenSequence([
      TweenSequenceItem(tween: Tween(begin: 0.0,end: 1.75), weight: 35.0),
      TweenSequenceItem(tween: Tween(begin: 1.75,end: 1.0), weight: 65.0)
    ])
        .animate(animationController);
You can see that we have passed different values to TweenSequence of all the three animations. This is because each of the button has to have some delay to add fluidity to animation. The magic happends with the “weight” that we are passing here. Weight basically represents the time that a particular tween should take.
By passing the values like this, animation for Button at 270 degree will complete fast, 225 degree will finish after that and 180 degree will finish last. Even the scale of these buttons will get affected by these tween values.
Once everything is done. Our final code will look something like this:
class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State with SingleTickerProviderStateMixin {
  AnimationController animationController;
  Animation degOneTranslationAnimation, degTwoTranslationAnimation, degThreeTranslationAnimation;
  Animation rotationAnimation;

  double getRadiansFromDegree(double degree) {
    double unitRadian = 57.295779513;
    return degree / unitRadian;
  }

  @override
  void initState() {
    animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 250));
    degOneTranslationAnimation = TweenSequence([
      TweenSequenceItem(tween: Tween(begin: 0.0,end: 1.2), weight: 75.0),
      TweenSequenceItem(tween: Tween(begin: 1.2,end: 1.0), weight: 25.0),
    ])
        .animate(animationController);
    degTwoTranslationAnimation = TweenSequence([
      TweenSequenceItem(tween: Tween(begin: 0.0,end: 1.4), weight: 55.0),
      TweenSequenceItem(tween: Tween(begin: 1.4,end: 1.0), weight: 45.0)
    ])
        .animate(animationController);
    degThreeTranslationAnimation = TweenSequence([
      TweenSequenceItem(tween: Tween(begin: 0.0,end: 1.75), weight: 35.0),
      TweenSequenceItem(tween: Tween(begin: 1.75,end: 1.0), weight: 65.0)
    ])
        .animate(animationController);
    rotationAnimation = Tween(begin: 180.0, end: 0.0)
        .animate(CurvedAnimation(parent: animationController, curve: Curves.easeOut));
    super.initState();
    animationController.addListener(() {
      setState(() {});
    });
  }

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

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      body: Container(
        width: size.width,
        height: size.height,
        child: Stack(
          children: [
            Positioned(
              right: 30,
              bottom: 30,
              child: Stack(
                children: [
                  Transform.translate(
                    offset: Offset.fromDirection(
                        getRadiansFromDegree(270), degOneTranslationAnimation.value * 100),
                    child: Transform(
                      transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value))
                        ..scale(degOneTranslationAnimation.value),
                      alignment: Alignment.center,
                      child: CircularButton(
                        color: Colors.blue,
                        width: 50,
                        height: 50,
                        icon: Icon(
                          Icons.add,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                  Transform.translate(
                    offset: Offset.fromDirection(
                        getRadiansFromDegree(225), degOneTranslationAnimation.value * 100),
                    child: Transform(
                      transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value))
                        ..scale(degOneTranslationAnimation.value),
                      alignment: Alignment.center,
                      child: CircularButton(
                        color: Colors.black,
                        width: 50,
                        height: 50,
                        icon: Icon(
                          Icons.camera_alt,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                  Transform.translate(
                    offset: Offset.fromDirection(
                        getRadiansFromDegree(180), degOneTranslationAnimation.value * 100),
                    child: Transform(
                      transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value))
                        ..scale(degOneTranslationAnimation.value),
                      alignment: Alignment.center,
                      child: CircularButton(
                        color: Colors.orangeAccent,
                        width: 50,
                        height: 50,
                        icon: Icon(
                          Icons.person,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                  Transform(
                    transform: Matrix4.rotationZ(getRadiansFromDegree(rotationAnimation.value)),
                    alignment: Alignment.center,
                    child: CircularButton(
                      color: Colors.red,
                      width: 60,
                      height: 60,
                      icon: Icon(
                        Icons.menu,
                        color: Colors.white,
                      ),
                      onClick: () {
                        if (animationController.isCompleted) {
                          animationController.reverse();
                        } else {
                          animationController.forward();
                        }
                      },
                    ),
                  ),
                ],
              ),
            )
          ],
        ),
      ),
    );
  }
}
All Done, ✌. Isn’t this amazing! …🤓 With just a few Animation Tricks, we have created a relatively complex animation in Flutter. If you want to watch this in a video format, for a better undertanding of what is happening, you can check out the Video Tutorial for this article here:
 
Note: There is currently a reported but with Transform Widget that it does not translate the gesture detection region along with the button.But this tutorial will help you in learning the fundamentals of doing such cool animations. Once the fix there is, i will update the code on github and put the same updates in the article. Thank you!

Tags: #flutter #flutterui #flutteranimation #flutterwidget #animation








0 Comments
Login to comment.
Recent Comments

Be the first to Comment. You can ask a Query or Share your toughts or Just say thanks.











© 2018 - Fluttercentral | Email to me - seven.srikanth@gmail.com