Flutter Challenge: 3D Bottom Navigation Bar

Recreating a 3D navigation bar in Flutter

This Flutter challenge is based on a Dribbble design by Dannniel which you can view here.

Unlike earlier Flutter challenges I’ve done, which focused more on recreating apps, the next few will focus on recreating specific designs or components that you can use in your apps.

Introduction

I came across this curious design on Dribbble a while ago and after extensively reading up on Transform widgets in Flutter, I got a few ideas on how to implement it. I tried it in multiple ways but in the end, the implementation was rather simple but involved Transform widgets a lot.

So, if you’re not used to transforms, I would recommend reading two articles, one my own and one by WM Leler from the Flutter team.

Starting out

Taking a look at the Navigation Bar we have, it looks like we have a number of boxes in a row and each of them rotates independently.

The questions we need to ask are:

  1. How do we create a box?
  2. How do we rotate the box?
  3. How do we create a BottomNavigationBar out of it?

Note: There may be better methods than what I’ve done. This article is an explanation of my method to do it. If you have any improvements, feel free to comment and let me know.

How do we create a Box?

Looking at the animation, we can see the user only ever sees two sides (If you add more sides, it may even look more convincing, but for this animation, two sides look okay).

First side:

Side when item is not selected

Second side:

Side when item is selected

You may notice that the sides have different colours. This emphasises the 3D effect in the navigation bar.

Now let’s take a look at creating the box sides separately.

First side:

Container(
width: double.infinity,
height: double.infinity,
color: frontColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
icon,
],
),
)

Note: The double.infinity simply sets the container to max width and height. Here, “front” refers to the fact that this side is in front of the user.

Second side:

Container(
width: double.infinity,
height: double.infinity,
color: backColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
icon,
text,
],
),
),

Basically the same thing with some text below the icon.

Now, we have the two sides, but how do we create a 3D object out of these?

We start with a Stack and add our two sides:

Stack(
children: <Widget>[
Container(
width: double.infinity,
height: double.infinity,
color: backColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
icon,
text,
],
),
),
Container(
width: double.infinity,
height: double.infinity,
color: frontColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
icon,
],
),
),
],
);

For simplicity, we’ll name the first container the backChild and the second the frontChild.

So the simplified version is:

Stack(
children: <Widget>[
backChild,
frontChild,
],
);

Because stack simply overlays stuff one over the other, our widget currently looks like this:

I’ve painted the front child red for clarity. Here we show a small difference in distance for visualization, but in reality, there is no distance between the front and back for now.

Now, before we look at code, let’s take a look at the basic things we need to do in order to turn two planes into a box:

  1. Pull the red container forward by a distance which is half of the height of the containers

2. Push the blue child up by a distance which is half the height of containers

3. Rotate the blue side by 90 degrees

Easy enough to do in theory, how do we do this in practice?

We use the Transform widget.

However, we won’t go into the code for now as we haven’t taken one major thing into account: Rotation.

How do we rotate the Box?

As you might now realise, this “box” is simply two planes (containers). To “rotate the box”, we need to rotate both the planes simultaneously.

Here comes the second big challenge: Axis.

To rotate around the X, Y or Z axis of a plane is relatively straightforward. But a box rotates around the X axis of the cube.

In theory, what we have to do now is very simple, bring the blue plane to the position of the red plane and the red plane below the cube (rotate both sides by 90 degrees around the axis).

To do this, we need to do two things, simultaneously :

  1. Rotate both planes by 90 degrees
  2. Translate them to their respective new positions

The translation is the most important aspect of the 3D effect.

Let’s see what the current translations are:

The red plane is height/2 in front of the axis, which is -(height/2) in the Z axis at the initial position, and 0 in both other directions. These are the initial translations of the red plane.

Similarly the blue plane has -(height/2) in the Y direction, since the positive Y axis is downwards.

The final translation of the red plane is (height/2) in the Y direction and 0 in both other directions, since it is directly below the axis.

The final translation of the blue plane is the initial translation of the blue plane, since the blue plane takes the place of the red plane in the end.

Imagine a point on the top plane of the of the box. When rotating, it traces out a circle. We need to calculate the distance of the point from the centre of the cube in the X and Y directions.

This comes out to be R * sin(x) in the Y direction and R * cos (x) in the Z direction ( Relative to the user), where R is the height of the plane. This gives us the translations on each axis at a specific angle.

Finally, we write an animation which runs from 0 to π/2 (90 degrees in radians).

animation = Tween(begin: 0.0, end: pi / 2).animate(
CurvedAnimation(parent: controller, curve: Curves.elasticInOut),
);

We use a elasticInOut curve to to have a springy effect on the cube.

The stack now becomes:

Stack(
children: <Widget>[
Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..translate(0.0, -(cos(animation.value) * (height/2)),
(-(height/2) * sin(animation.value)))
..rotateX(-(pi / 2) + animation.value),
child: Container(
child: Center(child: backChild),
),
),
Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..translate(
0.0,
(height/2) * sin(animation.value),
-((height/2) * cos(animation.value)),
)
..rotateX(animation.value),
child: Container(
child: Center(child: frontChild),
),
),
],
),

Let’s analyse the transforms line by line:

For the top plane:

..setEntry(3, 2, 0.001)

This gives the plane a 3D effect as it rotates. The article link at the top by WM Leler goes more into this effect.

..translate(0.0, -(cos(animation.value) * (height/2)),
(-(height/2) * sin(animation.value)))

The translate transform takes an X, Y and Z parameter. These are translations in those respective directions.

As we can see, we don’t have any translation in the X direction and hence the X parameter is 0.0.

In the Y direction, we can see the translation goes from -(height/2) to 0 and the Z translation does from 0 to -(height/2) degrees. This is the effect we need for the top plane.

..rotateX(-(pi / 2) + animation.value)

This simply rotates the plane around its X axis by the current animation value. For the top plane, we start with a rotation of -pi/2 since it is on the upper side of the box (It starts horizontal and ends vertical).

Similarly, we transform the plane in front and rotate and translate it to go below the plane.

One last thing: No matter what we translate or rotate the plane by, the plane on top is always going to be on top of the stack and hence we need to remove the plane when it goes above a certain angle, say 85 degrees, like this:

Stack(
children: <Widget>[
Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..translate(0.0, -(cos(animation.value) * 50),
(-50 * sin(animation.value)))
..rotateX(-(pi / 2) + animation.value),
child: Container(
child: Center(child: widget.bottomChild),
),
),
animation.value < (85 * pi / 180)
? Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..translate(
0.0,
50 * sin(animation.value),
-(50 * cos(animation.value)),
)
..rotateX(animation.value),
child: Container(
child: Center(child: widget.topChild),
),
)
: Container(),
],
),

This is all for a single box, what for an AppBar?

How to create the BottomNavigationBar?

We wrap our boxes with an Expanded and use multiple items in a row. We specify the row height to bound it vertically. We use multiple animation controllers to control each box and we can add callbacks to make a full fledged BottomNavigationBar.

Instead of going into the code here, I’ve uploaded it on Github so you can play around and tweak it to your needs.

Github Link:

I have also uploaded the FlipBoxBar as a package to Dart Pub for making it easy to use.

Dart Pub Link:

That’s it for this article! I hope you enjoyed it, and leave a few claps if you did. Follow me for more Flutter articles and comment for any feedback you might have about this article.

Feel free to check out my other profiles and articles as well: