DEV Community

loading...
Cover image for Animated splash screen in Flutter

Animated splash screen in Flutter

flutterclutter profile image flutter-clutter Originally published at flutterclutter.dev ・8 min read

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

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

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

Flutter animated splash screen step 1

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
)

Flutter animated splash screen blue

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.

FULL CODE

Discussion (0)

pic
Editor guide