【Flutterでの実装例つき】クロスプラットフォーム環境でアプリ起動時のSplash画面(Launch画面)を作成する方法

この記事は[全部俺 Advent Calendar 2018](https://adventar.org/calendars/3702)の2日目の記事です。
頑張っていきます。

# この記事で説明すること
このようなSplash画面(Launch画面)を作るとき、FlutterやReactNativeではどうやって作成するのかについて記載します。

<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/414a1811-6cb0-fc24-04d0-8b04d8a6825c.gif width=40%>

オリジナルのGopherくんは、Renée Frenchによってデザインされました。

# Splash画面の種類について
Splash画面は、大きく分けて次の2種類に大別できます。
（名称はマテリアルデザインのものになります。）

- Branded launch screen
  - アプリ起動ボタンが押された後、アプリが起動するまでの間に表示されるSplash画面
- Placeholder UI
  - 画面内でリソース読み込みなどを行っている間に表示されるSplash画面
  - Google画像検索やInstagramで画像読み込み中に表示される画面

<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/b8910643-9be2-057d-b7aa-ea76b7321ff6.png width=35%> <img src=https://qiita-image-store.s3.amazonaws.com/0/190807/66296d12-e316-197f-fc36-f277a4f29dd8.png width=35%>

左：Branded launch screen, 右：Placeholder UI
([material.io](https://material.io/design/communication/launch-screen.html#)より画像をお借りしました)

この記事では、Branded launch screenをクロスプラットフォーム環境で実装する方法について記載します。

# クロスプラットフォーム開発におけるSplash画面の実装方針
結論から言うと、それぞれのプラットフォームごとにSplash画面を実装する必要があります。
クロスプラットフォーム開発なのにそれぞれのプラットフォームごとにSplash画面を実装しなければならない理由には、クロスプラットフォームにおける実行環境が関わっています。

FlutterやReactNativeは、AndroidとiOS上で起動する実行環境上で動作しています。
実際にアプリが起動する際は、以下のような順番で処理が行われることになります。

1. アプリのアイコンがタップされ、Branded launch screenが表示される
2. クロスプラットフォームの実行環境の立ち上げが行われる（FlutterならDart VM、ReactNativeなら JavaScript Runtime Environment）
3. それぞれのクロスプラットフォーム上でコードが実行される

つまり、1. の時点ではクロスプラットフォーム実行環境が立ち上がっていないため、Branded launch screenを表示するコードをクロスプラットフォーム実行環境の上で記載することはできません！
（Placeholder UIについては記載することができます。）

以上の理由から、それぞれのプラットフォームごとにSplash画面を実装する必要があるのです。
注）Branded launch screenについては、読み込み時間を秒数で指定して表示することもできますが、UXを下げる原因になりうるので、この記事ではあくまでも実行環境の立ち上げまでの間に画面を表示することについて記載します。（Twitterを開く度に2秒間待たされる、という状況を考えるとうんざりしますよね？）

# FlutterにおけるSplash画面の実装例
上記の理由から、Splash画面の実装はAndroidとiOS双方で行う必要があるわけですが、実装はそこまで難しくありません。
それぞれのプラットフォームで表示したい画像を用意して決められた場所に配置し、設定ファイルを書き換えることになります。
ここではFlutterを想定した実装例を記載しますが、ReactNativeなどの他プラットフォーム開発においても同様に実装すればOKです。

## Android側の実装
### 画像ファイルの配置
下記のmipmap-xxxxディレクトリに、同名で作成したファイル(ここではlaunch_splash.png)を配置します。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/26b03c7b-d7b2-152a-1533-60c5c91881f5.png width=40%>

### styles.xmlの編集
背景色をHexカラーコードで`android/app/src/main/res/values/styles.xml`に定義します。

```styles.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
        <item name="android:windowBackground">@drawable/launch_background</item>
    </style>
    <color name="background">#84ffff</color>
</resources>
```

### launch_background.xmlの編集
上記で作成・定義した画像ファイルと背景色を`android/app/src/main/res/drawable/launch_background.xml`に定義します。

```launch_background.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:drawable="@color/background"/>

    <item
        android:width="200dp"
        android:height="200dp"
        android:drawable="@mipmap/launch_splash"
        android:gravity="center" />
</layer-list>
```

### buildして動作確認
buildして実行すると、以下のようなBranded launch screenが表示されます。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/414a1811-6cb0-fc24-04d0-8b04d8a6825c.gif width=40%>

アプリがメモリに残っているときにアプリ履歴から起動した場合は、Branded launch screenはもちろん表示されません。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/7a65c157-be63-f588-aac9-44bf1e71af9c.gif width=40%>

## iOS側の実装
### 画像ファイルの配置
XCodeからRunner.xcworkspaceを開き、Assets.xassets上のLaunchImageに画像をドラッグ＆ドロップで配置します。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/68ca40b0-8c34-01dc-ab3b-491d5796e380.png width=40%>
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/b6e4c692-2802-53f6-0f85-91eb51825546.png width=60%>

### LaunchScreen.storyboardの編集
「LaunchScreen.storyboard」→「View」→「Background」から「Custom」を選択し、Hexカラーコードで色を指定します。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/2d76b429-7dfd-acdf-826c-8ed2965acc0a.png width=60%>

必要であれば制約条件を追加します。
例えば、画面幅の0.5倍のサイズで中央に画像が表示されるように設定する方法は以下のようになります。

1. ViewとLaunchImageを選択して、画面右下の制約条件から「Equal Widths」を選択して「Add 1 Constraint」
2. 作成した制約条件を選び、「Multiplier」の値を「0.5」に変更
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/6c3fd467-fd0c-f3ab-cf50-6309fb0c31d1.png width=60%>
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/17ff027a-4652-530e-06e0-67d354ceefce.png width=60%>

### buildして動作確認
buildして実行すると、以下のようなBranded launch screenが表示されます。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/1ebdb942-9f11-f830-b95c-809ebdd481f9.gif width=40%>

アプリがメモリに残っているときにアプリ履歴から起動した場合は、Branded launch screenはもちろん表示されません。
<img src=https://qiita-image-store.s3.amazonaws.com/0/190807/29d274b9-ae8d-4eae-2900-73365301e5b9.gif width=40%>

## まとめ
- Splash画面はBranded launch screenとPlaceholder UIの2種類ある
- クロスプラットフォーム開発において、Branded launch screenはネイティブ実装を行う必要がある

## ハマりどころ
- iOSのLaunchScreenが更新されないとき
    - キャッシュが残ってしまっている可能性があるので、simulatorをrestartすると良いです。
    - Cleanだけでは直らない場合があります。
- Flutterにおいて、Android/iOSのBranded launch screenとFlutterのmain.dart画面の間に一瞬黒い画面が表示される場合
    - Flutterのバージョンによるバグです。
        - コマンドラインから`flutter upgrade`でアップグレードすれば直ります。
    - Branded launch screenとFlutterのmain.dartの間に、dart実行環境の立ち上げが挟まっているために起きていたようです。