Animated splash screen in Flutter
Tags: flutter, dart, tutorial, appdevelopment
Published: 2020-08-03 15:22:11

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

![The final animation](https://dev-to-uploads.s3.amazonaws.com/i/64jb4nepje31pxe81rti.gif)

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:

![Flutter splash screen animation steps](https://dev-to-uploads.s3.amazonaws.com/i/yd9a03jlihsckh79h5oz.png)

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.

```dart
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

```dart
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](https://flutter.dev/docs/development/ui/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 %.

```dart
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.

![Flutter animated splash screen step 1](https://dev-to-uploads.s3.amazonaws.com/i/ag0numm0fsgflczo5jmr.gif)

## 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.

```dart
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.

```dart
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](https://www.flutterclutter.dev/flutter/tutorials/how-to-cut-a-hole-in-an-overlay/2020/510/), 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.

```dart
  @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.

```dart
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;
  ...
}
```

```dart
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.

```dart
AnimationScreen(
  color: Theme.of(context).accentColor
)
```

![Flutter animated splash screen blue](https://dev-to-uploads.s3.amazonaws.com/i/5yc22n12723nnoat2ky2.gif)

## 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.

```dart
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
<?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
<?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](https://flutter.dev/docs/development/ui/advanced/splash-screen)

# 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.

[FULL CODE](https://github.com/flutter-clutter/flutter-animated-splash-screen)