"I am your Flutter" โ Dart Vader
I love Star Wars and I love Flutter. So after a brief hiatus from trying fun stuff with Flutter, and looking for good motivation to start writing code again, as well as some other personal reason ๐, I came up with this brilliant idea, as many before me have, apparently, to recreate the intro to the Star Wars movies in code. I did it with Flutter!
If you're still here then my bad jokes didn't bore you away and you're obviously interested, so let's dive in ๐คฟ.
Breakdown of components
There are five main elements in the intro
- The intro text
- The starry background
- The Star Wars logo
- The crawling text
- The music
We'll create widgets for all these components and animate them all together with Flutter's helpful Tween and Staggered animations.
Let's get started!
The intro text
A long time ago, but somehow in the future? The Star Wars intro begins with this famous phrase. Let's build a widget for it.
class IntroText extends StatelessWidget {
IntroText({this.opacity});
// This variable will be responsible for animating the opacity of this widget
final Animation<double> opacity;
@override
Widget build(BuildContext context) {
return Opacity(
// We adjust the opacity based on the value of the animation variable
opacity: opacity.value,
child: Center(
child: FittedBox(
child: Padding(
padding: const EdgeInsets.all(40.0),
child: Text(
'A long time ago in a galaxy far,\nfar away....',
style: TextStyle(
color: Color(0xFF11B2A3),
fontSize: 72.0,
),
),
),
),
),
);
}
}
Here we go...
Note - In order to display these widgets before the animation logic has been written you'd have to pass a temporary AlwaysStoppedAnimation<double>()
value to the constructor of the widget.
For example:
class TempView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Container(
color: Colors.black,
child: Stack(
children: [
IntroText(
opacity: AlwaysStoppedAnimation<double>(1),
),
],
),
),
);
}
}
The starry background
The background of the Star Wars intro is a simple black void of space dotted with stars. Instead of using a boring static image, let's create it ourselves.
Twinkle twinkle little star ๐
First, we'll create a star widget. This will be a simple, tiny Container widget.
class TwinkleLittleStar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 1.0,
height: 1.0,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
);
}
}
"Space: the final frontier."
oops, wrong franchise ๐
Anyway, let's go ahead and generate a bunch of these stars in our Space
widget.
import 'dart:math' as math;
...
// Our Space widget is stateful so that we can generate a bunch of random stars
// only once when the widget loads by overriding the didChangeDependencies method
class Space extends StatefulWidget {
Space({this.opacity});
final Animation<double> opacity;
@override
_SpaceState createState() => _SpaceState();
}
class _SpaceState extends State<Space> {
final int _starCount = 300;
List<Widget> _stars;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// We generate our stars in this method so that it builds only once
// but after the initState method has finished running.
_stars = _generateStars();
}
List<Widget> _generateStars() {
return List.generate(_starCount, (index) {
List<double> xy = _getRandomPosition(context);
return Positioned(
top: xy[0],
left: xy[1],
child: TwinkleLittleStar(),
);
});
}
List<double> _getRandomPosition(BuildContext context) {
// We get the dimensions of the screen and use them to generate random coordinates
double x = MediaQuery.of(context).size.height;
double y = MediaQuery.of(context).size.width;
double randomX =
double.parse((math.Random().nextDouble() * x).toStringAsFixed(3));
double randomY =
double.parse((math.Random().nextDouble() * y).toStringAsFixed(3));
return [randomX, randomY];
}
@override
Widget build(BuildContext context) {
return Opacity(
opacity: widget.opacity.value,
child: Material(
type: MaterialType.transparency,
child: Container(
color: Colors.black,
child: Stack(
children: _stars,
),
),
),
);
}
}
That's it! We have our starry background ๐. You can see our stars twinkling below, if you squint hard enough.
The Star Wars logo
Don't we all love it when the Star Wars logo jumps out of nowhere right on cue with the soundtrack? We can make that happen too but first we need the logo.
The logo we're using can be found at this link.
class StarWarsLogo extends StatelessWidget {
StarWarsLogo({this.size, this.opacity});
// This time there is an extra animation variable which will animate the size of the logo,
// progressively making it smaller and smaller to simulate it moving away.
final Animation<double> size;
final Animation<double> opacity;
@override
Widget build(BuildContext context) {
return Center(
child: Opacity(
opacity: opacity.value,
// We get the image straight from the net. This is a sci-fi after all...
child: Image.network(
'https://logos-download.com/wp-content/uploads/2016/09/Star_Wars_logo-1.png',
width: size.value,
fit: BoxFit.fitHeight,
),
),
);
}
}
Here's what we get:
The crawling text
What better way to deliver exposition than in bright yellow perspective text that slowly crawls up from the bottom of your screen! Let's see how we can create this widget.
class CrawlingText extends StatelessWidget {
CrawlingText({this.topMargin, this.bottomMargin, this.opacity});
// The `topMargin` and `bottomMargin` values are what are used to acheive the crawling effect
final Animation<double> topMargin;
final Animation<double> bottomMargin;
final Animation<double> opacity;
final TextStyle _crawlingTextStyle = TextStyle(
color: Color(0xFFFFC500),
fontSize: 40.0,
fontWeight: FontWeight.bold,
);
@override
Widget build(BuildContext context) {
final double maxWidthConstraint = MediaQuery.of(context).size.width * 0.6;
return Opacity(
opacity: opacity.value,
child: Center(
child: Transform(
// This transformation adjusts it's child to achieve the perspective angle we want
transform: Matrix4.identity()
..setEntry(3, 2, 0.007)
..rotateX(5.6),
alignment: FractionalOffset.center,
child: Container(
// Adjust the margins above and below the text to make it simulate upward movement
margin: EdgeInsets.only(
top: topMargin.value, bottom: bottomMargin.value),
child: FittedBox(
child: Container(
constraints: BoxConstraints(
maxWidth: maxWidthConstraint < 500
? MediaQuery.of(context).size.width
: maxWidthConstraint,
),
padding: EdgeInsets.symmetric(horizontal: 64.0),
child: Column(
children: [
Text(
'Episode VII\nTHE FORCE AWAKENS',
textAlign: TextAlign.center,
style: _crawlingTextStyle,
),
Text(
"\nLuke Skywalker has vanished. In his absence, the sinister FIRST ORDER has risen from the ashes of the Empire and will not rest until Skywalker, the last Jedi, has been destroyed."
"\n\nWith the support of the REPUBLIC, General Leia Organa leads a brave RESISTANCE. She is desperate to find her brother Luke and gain his help in restoring peace and justice to the galaxy."
"\n\nLeia has sent her most daring pilot on a secret mission to Jakku, where an old ally has discovered a clue to Luke's whereabouts.โฆ",
textAlign: TextAlign.justify,
style: _crawlingTextStyle,
),
],
),
),
),
),
),
),
);
}
}
The result will look something like this depending on the values you provide for the margins
And finally,
The music
There's no great movie without a great soundtrack and that of Star Wars is no exception. In order to add audio to our little project we're going to need the audioplayers package.
This package allows us to stream audio directly from the net and that's exactly what we're going to do by creating our own MyAudioPlayer
class.
The music we're using can be found at this link.
import 'package:audioplayers/audioplayers.dart';
...
class MyAudioPlayer {
static AudioPlayer audioPlayer = AudioPlayer();
static Future<void> playMusic() async {
int result = await audioPlayer.play(
"https://s.cdpn.io/1202/Star_Wars_original_opening_crawl_1977.mp3");
if (result == 1) {
// success
}
}
static Future<void> stopMusic() async {
int result = await audioPlayer.stop();
if (result == 1) {
// success
}
}
}
We can now use the โถ playMusic()
and โน stopMusic()
methods to control the music.
Animating the widgets
Now that we have all the components, we can begin animating them.
In order to animate these widgets one after the other or with overlapping animations we need a form of key-framing logic. Thankfully, Flutter has its own version of this called Staggered animations and it uses a bunch of Tweens all controlled by a single animation controller to animate the widgets in the order you specify.
To achieve our animation we will need to
- Create an
AnimationController
that manages all of the Animations. - Create a
Tween
for each property being animated. TheTween
defines a range of values. Also, the Tweenโs animate method requires the parent controller, and produces an Animation for that property. - Specify the interval on the Animationโs curve property.
Creating the AnimationController
We'll create the AnimationController
in our home
widget. It'll be a StatefulWidget
that extends TickerProviderStateMixin
as required by the AnimationController
.
import 'package:flutter/scheduler.dart' show timeDilation;
...
class StarWarsIntro extends StatefulWidget {
@override
_StarWarsIntroState createState() => _StarWarsIntroState();
}
class _StarWarsIntroState extends State<StarWarsIntro>
with TickerProviderStateMixin {
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)..addListener(_controllerListener);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// We listen to the controller and stop the music when the animation ends
void _controllerListener() {
if (_controller.value == 1) MyAudioPlayer.stopMusic();
}
// The animation will play to the end and then reset
Future<void> _playAnimation() async {
try {
await _controller.forward().orCancel;
_controller.reset();
} on TickerCanceled {
// the animation got canceled, probably because it was disposed of
}
}
@override
Widget build(BuildContext context) {
timeDilation = 80.0; // Time Dilation reduces the speed of the animation
return Material(
type: MaterialType.transparency,
child: Stack(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
// We simultaneously play the animation and the music when a tap is recognized
_playAnimation();
MyAudioPlayer.playMusic();
},
child: StarWarsIntroAnimation(
controller: _controller,
size: MediaQuery.of(context).size,
),
),
],
),
);
}
}
Creating Tweens and specifying the interval of animation
Next, we'll create a StatelessWidget
in which we'll create the Tweens of all the properties being animated. The build()
function of this widget instantiates an AnimatedBuilder.
AnimatedBuilder listens to notifications from the animation controller, marking the widget tree dirty as values change and calls our function _buildAnimation()
which rebuilds the UI for each tick of the animation.
class StarWarsIntroAnimation extends StatelessWidget {
StarWarsIntroAnimation({Key key, this.controller, this.size})
// Each property that is going to be animated has a `Tween` created for it
// The main AnimationController is passed to each of them and their
// interval of animation is specified
: introTextOpacityShow = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: controller,
// The interval are two values from 0.0 to 1.0 which will take up a
// fraction of the AnimationCOntroller's total interval and
// specify at which values of the AnimationController this `Tween` will animate
curve: Interval(
0.0,
0.01,
curve: Curves.ease,
),
),
),
introTextOpacityHide = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.08,
0.1,
curve: Curves.ease,
),
),
),
spaceOpacity = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.12,
0.12,
curve: Curves.ease,
),
),
),
starWarsLogoOpacityShow = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.12,
0.12,
curve: Curves.ease,
),
),
),
starWarsLogoOpacityHide = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.25,
0.3,
curve: Curves.ease,
),
),
),
starWarsLogoSize = Tween<double>(
begin: size.width,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.09,
0.35,
curve: Curves.decelerate,
),
),
),
crawlingTextPositionTop = Tween<double>(
begin: size.height,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.0,
0.8,
curve: Curves.linear,
),
),
),
crawlingTextPositionBottom = Tween<double>(
begin: 0.0,
end: size.height * 0.7,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.4,
1.0,
curve: Curves.linear,
),
),
),
crawlingTextOpacity = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: controller,
curve: Interval(
0.9,
1.0,
curve: Curves.linear,
),
),
),
super(key: key);
final Size size;
final AnimationController controller;
final Animation<double> spaceOpacity;
final Animation<double> introTextOpacityShow;
final Animation<double> introTextOpacityHide;
final Animation<double> starWarsLogoOpacityShow;
final Animation<double> starWarsLogoOpacityHide;
final Animation<double> starWarsLogoSize;
final Animation<double> crawlingTextOpacity;
final Animation<double> crawlingTextPositionTop;
final Animation<double> crawlingTextPositionBottom;
Widget _buildAnimation(BuildContext context, Widget child) {
return Container(
color: Colors.black,
child: Stack(
children: [
Space(opacity: spaceOpacity),
IntroText(
// We use two different Tweens to animate the same property
// at different intervals in the animation as required
// Here the opacity property will listen to the value of the introTextOpacityHide`
// if the controller's value is more than 0.09 and listen to the value of `introTextOpacityShow` otherwise
opacity: controller.value > 0.09
? introTextOpacityHide
: introTextOpacityShow,
),
StarWarsLogo(
opacity: controller.value > 0.2
? starWarsLogoOpacityHide
: starWarsLogoOpacityShow,
size: starWarsLogoSize,
),
CrawlingText(
topMargin: crawlingTextPositionTop,
bottomMargin: crawlingTextPositionBottom,
opacity: crawlingTextOpacity,
),
],
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
builder: _buildAnimation,
animation: controller,
);
}
}
Below is a visual representation of the Staggered animation timeline. It shows the intervals of all the Tweens in relation to each other and to the total interval of the AnimationController
.
โChewie, weโre home.โ โ Han Solo
Specify the StarWarsIntro
widget as your home
and we're set! Once you run the project grab your popcorn ๐ฟ, click on the screen and watch the intro unfold ๐ฌ.
void main() {
runApp(
MaterialApp(
title: 'Star Wars Intro',
home: StarWarsIntro(),
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.yellow,
),
),
);
}
Visit the GitHub repo to view the complete project.
Visit this CodePen to see it in action, but the caveat is that there's no music as CodePen doesn't support importing flutter packages yet ๐ข.
End Note
Well, that was fun!
Flutter's animation system is very cool and easy to use once you get the hang of it and from my experience, the only way to get good at it is to jump in feet first, and maybe write about your experience. It helps so solidify your understanding.
You can visit this helpful doc by the Flutter team if you need further clarification of Staggered animations.
I would've had a hard time writing this project and article without this article by Christoper Kade where he does the same thing in HTML/CSS!
May the force be with you
- ArthurDev