Animated splash screen in Flutter
flutter-clutter
Posted on August 3, 2020
Splash screens are an excellent way of setting the scene for the rest of the app. By showing the user an immersive animation, the attention can be increased and become longer-lasting. Apart from that it can make your app stand out in the huge pool of similar looking user interfaces.
We will give it a try with a raindrop falling into a symbolic water surface with the caused waves revealing what's underneath: the first screen of the app.
The goal
Let's describe what we want the animation to be like:
- Everything is initially covered by a solid color and the name of the app is displayed at the bottom
- At the top center, a raindrop originates, growing from zero to its final size
- The raindrop falls down until the center of the screen and disappears
- Two circles begin to grow from the center: one inner circle and one outer circle
- The inner circle makes the underlying UI-elements fully transparent
- The outer circle forming a ring around the inner circle makes the underlying UI-elements 50 % visible
So roughly speaking we have these four phases:
It starts with a raindrop at the top, falling down (1). When the raindrop reaches 50 % of the height, it disappears (2) and a hole is created (3). It grows until the underlying widget is visible (4).
The implementation
Let's start with the implementation by initializing the MaterialApp
with our (to be implemented) raindrop animation and the actual first screen below that.
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Raindrop App',
theme: ThemeData(
primarySwatch: Colors.red,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Material(
child: Stack(
children: <Widget>[
Scaffold(
appBar: AppBar(
title: Text('Raindrop App'),
),
body: ExampleStartScreen()
),
AnimationScreen(
color: Theme.of(context).accentColor
)
]
)
);
}
}
We achieve this by using a Stack
widget. Important: since we want to have all the benefits from a Scaffold widget but don't want to nest it as this is not a good practice, we put the Scaffold containing the first real screen at the bottom of the Stack and our AnimationScreen
on top of that. Not having a Scaffold
above our AnimationScreen
would mean that we miss our Theme. That would cause ugly text to be rendered and also we would not be able to access our theme color. That's why we set Material
as the root widget.
Animating the drop
class StaggeredRaindropAnimation {
StaggeredRaindropAnimation(this.controller):
dropSize = Tween<double>(begin: 0, end: maximumDropSize).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.0, 0.2, curve: Curves.easeIn),
),
),
dropPosition = Tween<double>(begin: 0, end: maximumRelativeDropY).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.2, 0.5, curve: Curves.easeIn),
),
);
final AnimationController controller;
final Animation<double> dropSize;
final Animation<double> dropPosition;
static final double maximumDropSize = 20;
static final double maximumRelativeDropY = 0.5;
}
We start by implementing a class that holds all the animations. Initially, we only want the drop to grow and then to move to the vertical center of the screen. The class is named StaggeredRaindropAnimation
and expects the AnimationController
as the only argument. The fields of the class are both of the animations dropSize
and dropPosition
which store the animations as well as maximumDropSize
and maximumRelativeDropY
which store the maximum value of the respective animations. In the constructor we initiate the animations using Tween
s from 0 to the defined maximum values. The genesis of the raindrop claims the first 20 % of the time (0.0 to 0.2), the fall ranges from 20 % to 50 %.
class AnimationScreen extends StatefulWidget {
AnimationScreen({
this.color
});
final Color color;
@override
_AnimationScreenState createState() => _AnimationScreenState();
}
class _AnimationScreenState extends State<AnimationScreen> with SingleTickerProviderStateMixin {
Size size = Size.zero;
AnimationController _controller;
StaggeredRaindropAnimation _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 3000),
vsync: this,
);
_animation = StaggeredRaindropAnimation(_controller);
_controller.forward();
_controller.addListener(() {
setState(() {});
});
}
@override
void didChangeDependencies() {
setState(() {
size = MediaQuery.of(context).size;
});
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
color: widget.color
),
Positioned(
top: _animation.dropPosition.value * size.height,
left: size.width / 2 - _animation.dropSize.value / 2,
child: SizedBox(
width: _animation.dropSize.value,
height: _animation.dropSize.value,
child: CustomPaint(
painter: DropPainter(),
)
)
)
]
);
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
}
We use a Stack
widget as the root of the tree of our new widget AnimationScreen
. At the bottom of the Stack widget we place a Container with the color from the constructor argument. One level above that, we create a Positioned
widget. The top vale is the dropPosition
(that ranges from 0 to 0.5) times the height, making it fall from the top to the center. As a child we place a SizedBox
with the size of the drop that is also animated using the dropSize
value.
Animating the hole
The moment the raindrop touches the center we want it do disappear and a hole to open. The hole should make the underlying UI elements visible. Around the hole there should be a ring that makes it half-transparent.
class StaggeredRaindropAnimation {
StaggeredRaindropAnimation(this.controller):
dropSize = Tween<double>(begin: 0, end: maximumDropSize).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.0, 0.2, curve: Curves.easeIn),
),
),
dropPosition = Tween<double>(begin: 0, end: maximumRelativeDropY).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.2, 0.5, curve: Curves.easeIn),
),
),
holeSize = Tween<double>(begin: 0, end: maximumHoleSize).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.5, 1.0, curve: Curves.easeIn),
),
),
dropVisible = Tween<bool>(begin: true, end: false).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.5, 0.5),
),
);
final AnimationController controller;
final Animation<double> dropSize;
final Animation<double> dropPosition;
final Animation<bool> dropVisible;
final Animation<double> holeSize;
static final double maximumDropSize = 20;
static final double maximumRelativeDropY = 0.5;
static final double maximumHoleSize = 10;
}
In our StaggeredRaindropAnimation
we add two new animations: holeSize
and dropVisible
. The hole should only start to grow when the raindrop reaches the center. Hence we set the interval range from 0.5 to 1.0. At the same time the drop is to disappear.
Next, we need a painter that takes the animated holeSize
and uses it to draw a growing hole to the center.
class HolePainter extends CustomPainter {
HolePainter({
@required this.color,
@required this.holeSize,
});
Color color;
double holeSize;
@override
void paint(Canvas canvas, Size size) {
double radius = holeSize / 2;
Rect rect = Rect.fromLTWH(0, 0, size.width, size.height);
Rect outerCircleRect = Rect.fromCircle(center: Offset(size.width / 2, size.height / 2), radius: radius);
Rect innerCircleRect = Rect.fromCircle(center: Offset(size.width / 2, size.height / 2), radius: radius / 2);
Path transparentHole = Path.combine(
PathOperation.difference,
Path()..addRect(
rect
),
Path()
..addOval(outerCircleRect)
..close(),
);
Path halfTransparentRing = Path.combine(
PathOperation.difference,
Path()
..addOval(outerCircleRect)
..close(),
Path()
..addOval(innerCircleRect)
..close(),
);
canvas.drawPath(transparentHole, Paint()..color = color);
canvas.drawPath(halfTransparentRing, Paint()..color = color.withOpacity(0.5));
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
In the same matter that is used in my tutorial on how to cut a hole in an overlay, we first draw a rectangle that fills the hole size of the canvas. Then we create a hole by using PathOperation.difference
to substract a centered oval from the rect. We then use a hole with half of the radius and subtract that from the bigger oval to have the half-transparent outer ring.
Lastly, we need to replace the solid color in the background by the HolePainter
we have just created.
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
width: double.infinity,
height: double.infinity,
child: CustomPaint(
painter: HoleAnimationPainter(
color: widget.color,
holeSize: _animation.holeSize.value * size.width
)
)
),
Positioned(
top: _animation.dropPosition.value * size.height,
left: size.width / 2 - _animation.dropSize.value / 2,
child: SizedBox(
width: _animation.dropSize.value,
height: _animation.dropSize.value,
child: CustomPaint(
painter: DropPainter(
visible: _animation.dropVisible.value
),
)
)
)
]
);
}
Adding some text
A splash screen mostly contains the logo or the title of your app. Let's extend the existing solution by displaying a text that is faded in and out.
class StaggeredRaindropAnimation {
...
textOpacity = Tween<double>(begin: 1, end: 0).animate(
CurvedAnimation(
parent: controller,
curve: Interval(0.5, 0.7, curve: Curves.easeOut),
),
);
...
final Animation<double> textOpacity;
...
}
Padding(
padding: EdgeInsets.only(bottom: 32),
child: Align(
alignment: Alignment.bottomCenter,
child: Opacity(
opacity: _animation.textOpacity.value,
child: Text(
'Raindrop Software',
style: TextStyle(
color: Colors.white, fontSize: 32
),
)
)
)
)
We let the text be there from the beginning and disappear between 50 % and 70 % of the animation so that it's readable most of the time but disappears when the hole reaches its boundaries.
Flexibility
If we provide the accent color to the animation, the color of the animation changes along with that.
AnimationScreen(
color: Theme.of(context).accentColor
)
The UI is not scrollable
You might have noticed that after the underlying UI has become visible, you can not interact with it. No gesture is being recognized. That's because we haven't told Flutter to forward the captures gestures. We use an IgnorePointer
to fix that.
IgnorePointer(
child: AnimationScreen(
color: Theme.of(context).accentColor
)
)
Usage as splash screen
Okay, so we created an animation that looks like a splash screen. But how do we use it as such? Well, neither Android nor iOS provides the possibility to have an animated splash screen. However, we can create the illusion of this animation belonging to the splash screen by having a seamless transition from the static one. In order to achieve that, we let the OS specific launch screen be a screen with only one color (the very same color we use for the animation).
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<color name="primary_color">#FF71ac29</color>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/primary_color" />
</layer-list>
For Android, we edit the files android/app/src/main/res/drawable/launch_background.xml
and android/app/src/main/res/values/styles.xml
like it can be seen above where primary_color
needs to be set to our splash color.
On iOS, an empty splash screen has already been set up. To change it, you need to open the Flutter app with Xcode project. Afterwards select Runner/Assets.xcassets from the Project Navigator and change the given color to the one of our splash screen.
For more information have a look at Flutter's official page about splash screens
Final thoughts
We have created an animated splash screen by stacking an animation with transparent elements on top of our first app screen. The transition from the native splash screen to that animation is achieved by having a static color screen that looks exactly like the first frames of our animation.
Posted on August 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.