I recently found out it's been over twenty years since the release of The Matrix. The digital rain of letters is probably known to almost everyone even those who haven't watched the movie. Let's give it a try and mimic this effect using Flutter.
Having seen a few videos of the effect, I can summarize it as follows:
- It consists of numerous "character rains" that all start at the top of the screen with a random horizontal position
- Every now and then a new rain spawns
- The latest character is always white, the colors before are green and there is a gradient so that the first characters fade out
- Background is black
- The characters are random characters including letters, numbers and other characters
- The vertical rains differ in the length (height) of visible characters
Implementation
Let's start with the implementation of a single vertical rain. Once we have that, we let the whole effect be a composition of multiple rains.
import 'package:flutter/material.dart';
class VerticalTextLine extends StatefulWidget {
VerticalTextLine({
this.speed = 12.0,
this.maxLength = 10,
Key key
}) : super(key: key);
final double speed;
final int maxLength;
@override
_VerticalTextLineState createState() => _VerticalTextLineState();
}
class _VerticalTextLineState extends State<VerticalTextLine> {
List<String> _characters = ['T', 'E', 'S', 'T'];
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: _getCharacters(),
);
}
List<Widget> _getCharacters() {
List<Widget> textWidgets = [];
for (var character in _characters) {
textWidgets.add(
Text(character, style: TextStyle(color: Colors.white),)
);
}
return textWidgets;
}
}
This is a very simple start. Just a widget that displays a static vertical line of text at the top of its container.
The constructor arguments are speed and maxLength
, which will later determine, how quickly the rain runs down the screen and what the maximum length of the visible text is. This way we have a variance in position, speed and length.
Now, instead of displaying a plain white text, we want the last character to be white and the text before that to have the above described gradient.
@override
Widget build(BuildContext context) {
List<Color> colors = [Colors.transparent, Colors.green, Colors.green, Colors.white];
double greenStart;
double whiteStart = (_characters.length - 1) / (_characters.length);
if (((_characters.length - _maxLength) / _characters.length) < 0.3) {
greenStart = 0.3;
}
else {
greenStart = (_characters.length - _maxLength) / _characters.length;
}
List<double> stops = [0, greenStart, whiteStart, whiteStart];
return _getShaderMask(stops, colors);
}
ShaderMask _getShaderMask(List<double> stops, List<Color> colors) {
return ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: stops,
colors: colors,
).createShader(bounds);
},
blendMode: BlendMode.srcIn,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: _getCharacters(),
)
);
}
For the purpose of displaying the text as a gradient, we use something called a ShaderMask
. This can be used to fade out the edge of a child. The shaderCallback
parameter expects a function that receives a bounding rectangle and returns a Shader
. Good thing the Gradient
class provides a createShader()
method. This enables us to control the shading behavior by using a Gradient.
We use blendMode.srcIn
. That means: take the source (the child parameter) and use the destination (shaderCallback
return value) as a mask. This leads to the text staying the way it is with the gradient only defining its color.
The stops argument of a Gradient is a list of doubles that represent the relative position across the gradient, at which a certain color (which is defined at the same position in the colors argument) should begin. So if the stops list contained 0 and 0.5 and the colors list contained Colors.white
and Colors.red
, the gradient would start with white and interpolate to red until half of its height and would be red from there to the bottom.
We want it to start with transparent color and then interpolate to green (at the character that is located maxLength
characters before the end of the column). This color should last until the penultimate visible character where it changes to white.
If the whole character list is smaller than maxLength
, we just take 30 % as a static point for the green color to start.
Next step is to turn this text with a static length into a growing column.
class _VerticalTextLineState extends State<VerticalTextLine> {
int _maxLength;
Duration _stepInterval;
List<String> _characters = [];
Timer timer;
@override
void initState() {
_maxLength = widget.maxLength;
_stepInterval = Duration(milliseconds: (1000 ~/ widget.speed));
_startTimer();
super.initState();
}
void _startTimer() {
timer = Timer.periodic(_stepInterval, (timer) {
final _random = new Random();
final list = ['A', 'B', 'C'];
String element = list[_random.nextInt(list.length)];
setState(() {
_characters.add(element);
});
});
}
...
speed
is interpreted in the way that 1.0 means 1 character per second. So it's the frequency of characters added per second.
Instead of having a static list of allowed characters, we can also use a random int to create a String from a UTF character code:
String element = String.fromCharCode(
_random.nextInt(512)
);
Now we need to create a widget that composes multiple of our vertical text lines:
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_matrix_effect/vertical_text_line_current.dart';
class MatrixEffect extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _MatrixEffectState();
}
}
class _MatrixEffectState extends State<MatrixEffect> {
List<Widget> _verticalLines = [];
Timer timer;
@override
void initState() {
_startTimer();
super.initState();
}
@override
Widget build(BuildContext context) {
return Stack(
children: _verticalLines
);
}
void _startTimer() {
timer = Timer.periodic(Duration(milliseconds: 300), (timer) {
setState(() {
_verticalLines.add(
_getVerticalTextLine(context)
);
});
});
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
Widget _getVerticalTextLine(BuildContext context) {
Key key = GlobalKey();
return Positioned(
key: key,
left: Random().nextDouble() * MediaQuery.of(context).size.width,
child: VerticalTextLine(
speed: 1 + Random().nextDouble() * 9,
maxLength: Random().nextInt(10) + 5
),
);
}
}
Every 300 milliseconds, we add a new VerticalTextLine
to the list.
Since we're dealing with a list of widgets here, it's mandatory to use the key parameter of the widgets we add. Otherwise, if we manipulated the list by removing one element, a reference would be missing which would result in no widget being properly removed from the list (and from the widget tree).
We place the rains by using a Positioned
widget with a random left
parameter. Also, we let the speed vary from 1 to 10 and the maximum length from 5 to 15.
We still have one problem: Every vertical text line we spawn, stays in the widget tree forever. It might fade out over time because of our gradient, but it will always occupy memory. This leads to the app becoming very laggy after a short period of time. We need the rain to decide when to disappear and tell its parent.
VerticalTextLine({
@required this.onFinished,
this.speed = 12.0,
this.maxLength = 10,
Key key
}) : super(key: key);
final double speed;
final int maxLength;
final VoidCallback onFinished;
...
void _startTimer() {
timer = Timer.periodic(_stepInterval, (timer) {
final _random = new Random();
String element = String.fromCharCode(
_random.nextInt(512)
);
final box = context.findRenderObject() as RenderBox;
if (box.size.height > MediaQuery.of(context).size.height * 2) {
widget.onFinished();
return;
}
setState(() {
_characters.add(element);
});
});
}
We achieve the above mentioned exit condition by using a callback that is being called from the vertical text line. The condition to disappear is when the size of the rendered widget (the column) is twice as high as the current screen height. This way, the moment of disappearing should be smooth enough not to be noticed by the viewer. The timer does not need to be cancelled manually, because we do this onDispose
anyways.
The onFinished
callback we provide to the VerticalTextLine
looks as follows:
onFinished: () {
setState(() {
_verticalLines.removeWhere((element) {
return element.key == key;
});
});
},
Conclusion
Using a ShaderMask
, a LinearGradient
, a Timer
and a GlobalKey
, we were able to quickly mimic the well-known visual effect from The Matrix.
Top comments (3)
Cool effect, I hope sometimes I will use it.
Thanks for sharing this huge and really valuable post with us :D
You're welcome! :)
I also have one and planning to write about Flutter, so feel free to check it
dev.to/derva/say-hi-to-fluttter-3lke