FlutterでAndroid向けのSplashScreenを自前で実装する


# 概要
この記事は「**FlutterでAndroid向けのSplashScreenを自前で実装する**」という内容の記事です.

ライブラリを用いて`SplashScreen`を実装する情報は散見されるのですが, Android側の`layout` ファイルを呼び出す`SplashScreen`の実装に関する情報は少ないという印象です.

そこで今回は`SplashScreen`のライブラリを用いず, 自前で実装を行うことで**カスタマイズ性の高いSplashScreenを実装する方法**について紹介していきます.

最終的には, 次の動画のような「**アニメーション**」と「**フェードアウト**」を組み合わせた`SplashScreen`を実装します.

<iframe width="560" height="315" src="https://www.youtube.com/embed/wf7_vA73AJM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

# FlutterにおけるSplashScreenの実装の概要
Flutterはアプリ起動時にAndroid/iOSで実装されている画面を表示した後にFlutterで実装されている画面を呼び出します.

そのため, Flutter側で`SplashScreen`を実装したとしても次の動画のようにアプリを起動するとデフォルトでネイティブ側に設定されている**真っ白な画面**が表示されてしまいます.

<iframe width="560" height="315" src="https://www.youtube.com/embed/Iv2PRnDp6NY" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

そこで, Flutterで`SplashScreen`を実装するためにはAndroid/iOS, それぞれネイティブで実装を行っていく必要があります. 

具体的な`SplashScreen`を実装する手段としては, [flutter_native_splash](https://pub.dev/packages/flutter_native_splash)といったライブラリを利用するものがあります.

ライブラリを用いると実装が簡単である一方, 中心にロゴを表示させるだけといった風に**カスタマイズ性が乏しい**という欠点があります. 

# AndroidにおけるSplashScreenの実装
そこで, Androidの`layout`ファイル画面にてレイアウトを作成し, それを`SplashScreen`として設定していきます.

また, サンプルリポジトリは

https://github.com/mona-apk/android_splash_screen

となります.

## build.gradle の設定
最初に, `android>app`直下にある`build.gradle`にAndroid側で利用するライブラリの設定を行っていきます.

`dependencies `にアニメーションのライブラリである[Lottie](https://airbnb.design/lottie/)に加えて, `constraint layout`も追加します.

```build.gradle
dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.airbnb.android:lottie:4.0.0' // 追加
    implementation "androidx.constraintlayout:constraintlayout:2.1.0" // 追加
}
```

## AndroidManifest.xml の設定
次に, `AndroidManifest.xml` の設定を行っていきます.

`AndroidManifest.xml`を見ると, デフォルトでは`res>drawable`にある`launch_background.xml`が`SplashScreen`に設定されていることがわかります.

```launch_background.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="@android:color/white" />

    <!-- You can insert your own image assets here -->
    <!-- <item>
        <bitmap
            android:gravity="center"
            android:src="@mipmap/launch_image" />
    </item> -->
</layer-list>
```

アプリ起動時に表示される**真っ白な画面**の実態は, `launch_background.xml`であり簡単な`SplashScreen`の実装であればこのファイルを編集するだけでも行うことができます.

今回は, `constraint layout` を用いた`SplashScreen`の実装を行っていきたいので

1. `activity`タグの`android:theme="@style/LaunchTheme"` を`android:theme="@style/NormalTheme"`に置き換える
2. `io.flutter.embedding.android.SplashScreenDrawable` という名前の`meta-data`タグを削除

をしていきます. (次に示す`AndroidManifest.xml`は一部記述を省略しています)

```AndroidManifest.xml
<manifest>
   <application>
        <!-- 1. activityタグの
                android:theme="@style/LaunchTheme" を
                android:theme="@style/NormalTheme" に置き換える -->
        <activity  
            android:name=".MainActivity"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme">
            <!-- Note: 
                 次のio.flutter.embedding.android.NormalTheme という名前のmeta-dataタグが無ければ追加 -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <!-- 2. io.flutter.embedding.android.SplashScreenDrawable という名前のmeta-dataタグを削除 -->
            <meta-data
              android:name="io.flutter.embedding.android.SplashScreenDrawable"
              android:resource="@drawable/launch_background"
              />
        </activity>

        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>
```

注意点として,

- `io.flutter.embedding.android.NormalTheme`という名前のタグが`AndroidManifest.xml`に無い場合がある
- `@style/NormalTheme`が`res>values>styles.xml`に定義されていないことがある

というものがあります.

`@style/NormalTheme`が定義されていなかった場合, `res>values>styles.xml`に次のように定義してください.

```styles.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
        <item name="android:windowBackground">@drawable/launch_background</item>
    </style>
    <!-- 追加 -->
    <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
        <!-- Note: 場合に応じて変更を行う -->
        <item name="android:windowBackground">?android:colorBackground</item>
    </style>
</resources>
```

また, Android側で`SplashScreen`を実装したとしても真っ白な画面が挿入されてしまうことがあります.

これは, [App startup time | Android Developers](https://developer.android.com/topic/performance/vitals/launch-time)によると`アプリの起動時にシステムプロセスが描画する空白の初期画面`が原因であり, 有色のスプラッシュ画面であった場合特に顕著に目立ちます.

そのような場合は`NormalTheme`を次のように変更してください.

```styles.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
        <item name="android:windowDisablePreview">true</item>
    </style>
</resources>
```

## レイアウトの作成
次に`SplashScreen`に表示するレイアウトを作成していきます.

`res`ディレクトリ直下に`layout`ディレクトリを作り, `splash_view.xml`という名前で次のようにレイアウトファイルを用意します.

```splash_view.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="#FFFFFF"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <!-- TODO: 実装 -->
</androidx.constraintlayout.widget.ConstraintLayout>
```

この`constraint layout`にラップされている`splash_view.xml`を変更することで`SplashScreen`のレイアウトを作成することができます.

## Lottie の設定
[Lottie](http://airbnb.io/lottie/)は, jsonとして書き出されたAdobe After EffectsのアニメーションをAndroid, iOS, Web, Windows上でネイティブにレンダリングすることができるライブラリです. 

[LottieFiles](https://lottiefiles.com/)というサイトで多くのクリエイターがアニメーションを公開してくれています.

今回は, [LottieFiles](https://lottiefiles.com/)でダウンロードできる[Christopher Ortiz](Christopher Ortiz)さんの[イルカのアニメーション](https://lottiefiles.com/71696-dolphin)を`dolphin.json`という名前で保存し利用します. 

ダウンロードしたjsonファイルは[Lottie for Android](https://github.com/airbnb/lottie-android)の慣例に従い, `res`ディレクトリ直下に`raw`ディレクトリを作成し`raw`ディレクトリに保存します.

そして, `splash_view.xml`を`LottieAnimationView`を用いて次のように変更します.

```splash_view.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="#FFFFF"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <com.airbnb.lottie.LottieAnimationView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:lottie_autoPlay="true"
        app:lottie_rawRes="@raw/dolphin"
        app:lottie_loop="true"
        app:lottie_speed="1.00" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

## SplashScreenの実装
最後に`SplashScreen`を実装していきます.

まず, `SplashScreen`クラスを継承した`CustomSplashScreen`を作成します.

```CustomSplashScreen.kt
package com.example.android_splash_screen

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import io.flutter.embedding.android.SplashScreen

class CustomSplashScreen : SplashScreen {
    // 1. splash_view.xmlをInflate
    override fun createSplashView(context: Context, savedInstanceState: Bundle?): View? =
            LayoutInflater.from(context).inflate(R.layout.splash_view, null, false)

    // 2. Flutterのフレームのレンダリングの準備ができたら呼び出される
    override fun transitionToFlutter(onTransitionComplete: Runnable) {
        // 3. CustomSplashScreen (SplashScreen)の削除
        onTransitionComplete.run()
    }
}
```

`io.flutter.embedding.android.SplashScreen`を継承した`CustomSplashScreen`クラスの役割は,

- `SplashScreen`のViewを生成
- Flutterのフレームのレンダリングの準備ができたら`SplashScreen`を非表示(削除)にする

の2つです.

`transitionToFlutter()`はFlutterのフレームのレンダリングの準備ができた直後に呼び出されれるのですが, `onTransitionComplete.run()`の呼び出しのタイミングを調整することで**フェードアウト**などのアニメーションを付け加えることができます. 

最後に, `MainActivity`で`provideSplashScreen()`をオーバライドして`SplashScreen`の実装は終わりです.

```MainActivity.kt
package com.example.android_splash_screen

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.SplashScreen

class MainActivity: FlutterActivity() {
    // provideSplashScreen()のオーバーライド
    override fun provideSplashScreen(): SplashScreen? = CustomSplashScreen()
}

```

## フェードアウトの実装
おまけとして, `SplashScreen`に**フェードアウト**のアニメーションを付けていきます.

そこで, `View`の保持とアニメーションを行う`CustomSplashScreenView`クラスを作成します.

```CustomSplashScreenView.kt
package com.example.android_splash_screen

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Context
import android.graphics.Color
import android.view.Gravity
import android.view.ViewPropertyAnimator
import android.view.LayoutInflater
import android.widget.FrameLayout

class CustomSplashScreenView(context: Context) : FrameLayout(context) {
    // 1. 変数
    private val transitionTimeInMills = 5000L
    private var onTransitionComplete: Runnable? = null
    private var fadeAnimator: ViewPropertyAnimator? = null

    init {
        // 2. SplashScreenのViewの生成
        val layoutInflater: LayoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        addView(layoutInflater.inflate(R.layout.splash_view, this, false),
                LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER))
    }
    
    // 3. フェードアウトアニメーションの定義
    fun animatePlay(onTransitionComplete: Runnable?) {
        this.onTransitionComplete = onTransitionComplete
        fadeAnimator = animate()
                .alpha(0.0f)
                .setDuration(transitionTimeInMills)
                .setListener(transitionAnimatorListener)
        fadeAnimator?.start()
    }

    private val transitionAnimatorListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
        // 4. アニメーション終了時に onTransitionComplete?.run() を呼び出す
        override fun onAnimationEnd(animation: Animator) {
            animation.removeAllListeners()
            if (onTransitionComplete != null) {
                // 5. CustomSplashScreen (SplashScreen) の削除
                onTransitionComplete?.run()
            }
        }
    }
}

```

そして, `CustomSplashScreen`も以下のように変更します.

```CustomSplashScreen.kt
package com.example.android_splash_screen

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import io.flutter.embedding.android.SplashScreen

class CustomSplashScreen : SplashScreen {
    private var splashView: CustomSplashScreenView? = null
    override fun createSplashView(context: Context, savedInstanceState: Bundle?): View? {
        if (splashView == null) {
            splashView = CustomSplashScreenView(context)
        }
        return splashView
    }

    override fun transitionToFlutter(onTransitionComplete: Runnable) {
        if (splashView != null) {
            // 1. Flutterのフレームのレンダリングの準備ができたらフェードアウト
            splashView?.animatePlay(onTransitionComplete)
        }
        else {
            onTransitionComplete.run()
        }
    }
}
```

上記のように`onTransitionComplete.run()`を呼び出すタイミングを操作することでフェードアニメーションであったり, `SplashScreen`の表示時間の制御であったりを行うことができます.

# おわりに
今回, FlutterでAndroid向けの`SplashScreen`を実装する方法についてまとめてみました.

ライブラリを用いないことで実装のコストは大きくなるものの, 色んなカスタマイズをしていくことができるのは楽しいなと思います.

iPhone編についても余力があればまとめてみたいなと思います.

# 参考
- [FlutterActivity](https://api.flutter.dev/javadoc/io/flutter/embedding/android/FlutterActivity.html)
- [App startup time | Android Developers](https://developer.android.com/topic/performance/vitals/launch-time)
- [Adding a splash screen to your mobile app](https://flutter.dev/docs/development/ui/advanced/splash-screen)
- [Lottie for Android](https://github.com/airbnb/lottie-android)