Blog · · 4 min read

3D Kinetic Type Poster in Flutter

In this post, we’re going to learn how to create probably the most useless effect on this blog. Why would you want to learn such a thing? Well, it looks really cool and maybe you will find some cool applications for it! 😀 The design is named 3D CSS Kinetic Type Poster. It was created by Pete Barr, who shared the design on dribbble and the front-end implementation on codepen. Let’s see if we can do it in Flutter as well!

Unfortunately, I didn’t get to create the entrance animation, so we will focus only on the main rotation. 😉


Static text

Let’s start by creating a “LINEAR” text widget. We need it to be rotated, so the best option in this scenario is to use RotatedBox. It’s pretty straight forward:

main.dart
class LinearText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RotatedBox(
quarterTurns: 3,
child: Text(
'LINEAR',
style: TextStyle(
color: Colors.white,
fontSize: 110,
),
),
);
}
}

Illustration: Static text

Now in order to add nice fade at the bottom of the text, we can put LinearGradient on top of our current widget. The gradient will go from black at the bottom, to transparent at the top. The following code shows a bit more advanced formula just to make it look nicer but the idea is the same:

main.dart
class LinearText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RotatedBox(
quarterTurns: 3,
child: Container(
child: Text(
'LINEAR',
style: TextStyle(
color: Colors.white,
fontSize: 110,
),
),
foregroundDecoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
Colors.black.withOpacity(0.9),
Colors.transparent
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
stops: [0.0, 0.2, 0.8],
),
),
),
);
}
}

Now our LinearText will look like this:

Illustration: Static text


Placing texts in a cilinder

Ok, we got the Linear Text, now it’s time to have 20 of those. In order to do it, we will generate list of 20 widgets and put them inside a Stack.

home_page.dart
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
bool isOnRight(double rotation) => math.cos(rotation) < 0;
@override
Widget build(BuildContext context) {
final numberOfTexts = 20;
return Scaffold(
body: SizedBox.expand(
child: Stack(
alignment: Alignment.center,
children: List.generate(
numberOfTexts,
(index) {
return LinearText();
},
),
),
),
);
}
}

Obviously, putting 20 identical elements on top of each other doesn’t change anything in what’s displayed on the screen. What we need to do is to rotate and translate each of those texts. In order to do that, we can use Transform widget like presented in the following snippet:

home_page.dart
(index) {
double rotation = 2 * math.pi * index / numberOfTexts;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001) //<--add perspective to the transformation
..rotateY(rotation)
..translate(-120.0),
child: LinearText(),
);
},

Illustration: Placing texts in a cilinder

Now we can see 20 rotated and translated elements. However, as you probably can notice, it looks a bit odd on the left side of the image. The reason of such effect is that elements in the Stack are always covering the other ones based on their order in the children list. This means that the elements that are being clearly behind are being drawn on top of the others which looks terrible. 🤮

The graphics below can help you understand what is exactly happening there. You can see that the first element of the list (index 0 on left image) is covered by the last one (index 9). Now we need to figure out how to get from the order on the left to the order on the right, where elements in front of the user are never covered by the elements behind them.

What we have

Illustration: Placing texts in a cilinder

What we need

Illustration: Placing texts in a cilinder

Luckily, we can achieve such effect in 2 simple steps:

First, we need to rotate each element by 90 degrees. This way we can easily separate the elements into left and right group.

Illustration: Placing texts in a cilinder

Then, all we need to do is flip the elements on the left side, so that the last one is in front (bottom of the image).

Illustration: Placing texts in a cilinder

Those 2 steps are easily implementable with the following code:

home_page.dart
class _HomePageState extends State<HomePage> {
bool isOnLeft(double rotation) => math.cos(rotation) > 0; //<---- check if angle is on the left
@override
Widget build(BuildContext context) {
...
(index) {
double rotation =
2 * math.pi * index / numberOfTexts + math.pi / 2; //<----add 90 degrees
if (isOnLeft(rotation)) {
rotation = -rotation; //<----flip on the left side
}
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(rotation) //<----use the updated rotation
..translate(-120.0),
child: LinearText(),
);
},
...
}
}

And now it will look much better:

Illustration: Placing texts in a cilinder


Adding movement

Now it’s time to make all those texts move! As usual, we will start with creating an AnimationController, which will drive the whole animation:

home_page.dart
class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
...
}

Then, we need to update build method, so that for every element in the list, it will be moved by a small part of the circle. At first, we will need to use AnimatedBuilder widget, which will rebuild every text alongside the animation progress. Having that, all we need to do is take the animation’s value into account when calculating rotation value.

home_page.dart
(index) {
return AnimatedBuilder( //<--- wrap everything in AnimatedBuilder
animation: _animationController, //<--- pass the controller
child: LinearText(), //<--- set child for performance optimization
builder: (context, child) {
final animationRotationValue =
_animationController.value * 2 * math.pi / numberOfTexts; //<-- calculate animation rotation value
double rotation = 2 * math.pi * index / numberOfTexts +
math.pi / 2 +
animationRotationValue; //<-- add the animation value
if (isOnLeft(rotation)) {
rotation = -rotation +
2 * animationRotationValue - //<-- adjust the formula on the left side
math.pi * 2 / numberOfTexts;
}
return Transform(
...
);
},
);
},

The design

The implementation

And that’s it!

I know that my version is slower but it’s intentional – I like it this way. 🙂

The whole source code is available on my GitHub page.

Cheers! 🙂


PS: If you want to see how I got to the final result, be sure to see the uncut video on the process!