実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

アプリ起動時のスプラッシュスクリーンとアプリ説明

はじめに

今回はアプリ起動時のスプラッシュスクリーンと、初回起動時のアプリの使い方説明画面について見ていきます。

Flutterアプリは起動時にFlutterモジュールの読み込みと起動のために少し間があります。この部分を埋めるため、ネイティブのスプラッシュスクリーンを付加できるパッケージを利用して簡単に最初の挨拶画面を構成していきます。

次に初回起動時に定番の、横スクロールで数ページのアプリの使い方のコツを説明する画面を入れます。これも気の利いたパッケージを利用すれば非常に簡単に作ることができます。

初回起動かどうかを判断するには何らかの永続化データを利用して状態を記録する必要があります。ここでは以前にとりあげたHiveによって初回起動かどうかを記録し、最初に表示する画面を切り替えていきます。






スプラッシュとアプリ説明画面

今回扱うサンプルのうち、ポイントになる部分のスクリーンショットを見ていきます。

スクリーンショット

アプリ起動直後にスプラッシュスクリーンを表示します。これがないと真っ白な画面になって数秒待つことになります:

f:id:linkedsort:20211204204913p:plain:w350


Flutterアプリが起動すると、初回起動かどうかを判定し、初回の場合はアプリの説明画面を表示します:

f:id:linkedsort:20211204204430p:plain:w350


説明は任意の画面数で構成できますが、今回は3ページ構成にしています。下は2画面目です:

f:id:linkedsort:20211204204455p:plain:w350


そして3画面目までいくと「完了」ボタンが出現します:

f:id:linkedsort:20211204204516p:plain:w350


「完了」を押すと、通常アプリが起動します。2回目以降の起動ではスプラッシュスクリーンの直後にこちらの画面に遷移します:

f:id:linkedsort:20211204204604p:plain:w350

スプラッシュスクリーンの構成

まずスプラッシュスクリーンを作っていきます。ここでは人気パッケージの「flutter_native_splash」を用います。


pub.dev

これはFlutterアプリ起動前のネイティブの段階でスプラッシュスクリーンを構成するため、Dartコードではなく設定ファイルの記載していきます。

まずパッケージの依存性をpubspec.yamlに記載します。下記のコマンドを実行するか、上記リンクから最新のバージョン番号を転記します。

flutter pub add flutter_native_splash

そして「flutter pub get」を実行し、パッケージを読み込んでおきます。

次にスプラッシュスクリーンの設定を、同じくpubspec.yamlに追記していきます。

flutter_native_splash:
  color: "#42a5f5"
  image: images/image01.png
  android: true
  ios: true
  web: true
  android_gravity: center
  ios_content_mode: center
  web_image_mode: center
  fullscreen: true

必須なのは「color」のみで、指定した色でスクリーンを埋めます。「image」で表示する画像を指定できます。ここでは以前の画像回で解説したときと同様、imagesフォルダを作ってimage01.pngというファイル名の画像を用意しています。ここでは「png」しか使えないことに注意です。

「ios」「android」「web」はそれぞれのプラットフォームでスプラッシュを起動するかどうか。デフォルトtrueなので実際には書かなくてもOKです。起動したくない場合にfalseとします。

「android_gravity」「ios_content_mode」「web_image_mode」はそれぞれ表示内容の中央寄せや右寄せ、左寄せ、フルカバーなどを指定します。プラットフォームごとに実現できる表示が異なります。

Androidでは:

developer.android.com

iOSでは:

developer.apple.com

を参照して値を決めます。

webの場合はcenter、contain、stretch、coverから選びます。

「fullscreen」をtrueにすると、通知バーの部分が表示されなくなります。デフォルトはfalseです。

たくさん下記ましたが、基本的には「color」と「image」をとりあえず書いておけばOKです。

これが書けましたら下記コマンドを実行するだけでOKです:

flutter pub run flutter_native_splash:create

設定を変えた場合や、画像を変えた場合(ファイル名と設定を変えなくても)には、再び上記のコマンドを実行して再構成する必要があります。


アプリ説明画面のサンプルコード

次にアプリ説明画面の実装について見ていきます。

説明画面後には過去に扱った次のアプリをつなげています。その部分のコードの解説は省きますのでこちらをご参照ください:


flutter.gakumon.jp

初回起動かどうかの永続化は上記記事の流れでHiveを使います。ただこの部分について他の永続化を使われる方は、Hiveの部分はスキップしていただいてOKです。

まず最初に起動が初回かどうかを収めるためのデータクラスを用意しています。「lib/app_info.dart」に以下のファイルを記述します:

import 'package:hive/hive.dart';

part 'app_info.g.dart';

@HiveType(typeId: 0)
class AppInfo extends HiveObject{
  @HiveField(0)
  bool isFirst = true;
}

ここでは初回かどうかの「isFirst」のみのデータを現状載せています。他にもアプリ共通の設定を収めるクラスというイメージです。

次に「lib/todo_item.dart」です。こちらは上で説明した記事に詳細の記述がありますので、そちらをご参照下さい。

import 'package:hive/hive.dart';

part 'todo_item.g.dart';

@HiveType(typeId: 1)
class TodoItem extends HiveObject{
  @HiveField(0)
  String title;

  @HiveField(1)
  bool isDone = false;

  TodoItem(this.title);
}

上記のHive用クラスの記述が終わりましたら、タイプアダプタを以下のコマンドで生成します:

flutter packages run build_runner build

これで「app_info.g.dart」と「todo_item.g.dart」が生成されます。

そして「lib/main.dart」です。

//(1) 必要パッケージのインポート
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'todo_item.dart';
import 'app_info.dart';
import 'package:introduction_screen/introduction_screen.dart';

const todoBoxName = 'todoBox';
const infoBoxName = 'infoBox';

void main() async {
  //(2) Hiveの初期化
  await Hive.initFlutter();
  Hive.registerAdapter(TodoItemAdapter());
  Hive.registerAdapter(AppInfoAdapter());
  await Hive.openBox<TodoItem>(todoBoxName);
  await Hive.openBox<AppInfo>(infoBoxName);

  runApp(const MyApp());
}

class Controller extends GetxController {
  //ホーム画面のリストビュー上のTodo項目
  var mainListItems = <TodoItem>[].obs;

  //コントローラーが初期化されていれば真
  static var isInitialized = false;

  Controller() {
    if (!isInitialized) {
      isInitialized = true;
      var todoBox = Hive.box<TodoItem>(todoBoxName);
      mainListItems.addAll(todoBox.values);
    }
  }
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(3) 初回起動時かどうかを判定し、初回のみIntro()を表示
    var infoBox = Hive.box<AppInfo>(infoBoxName);
    Widget nextScreen = const Home();

    if (infoBox.isEmpty) {
      var info = AppInfo()..isFirst = false;
      infoBox.add(info);
      nextScreen = const Intro(); 
    }

    return GetMaterialApp(
        debugShowCheckedModeBanner: false,
        initialRoute: '/',
        getPages: [GetPage(name: '/', page: () => nextScreen)]);
  }
}

class Intro extends StatelessWidget {
  const Intro({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(4) イントロスクリーンの設定
    return IntroductionScreen(
      pages: [
        PageViewModel(
          title: "右下のボタンを押して新規登録",
          body: "Todo項目を新規作成するときは、右下のボタンを押すことで登録画面に移ることができます。",
          image: Image.network(
              "https://cdn.pixabay.com/photo/2015/09/21/00/54/plant-949111_1280.jpg"),
        ),
        PageViewModel(
          title: "登録画面で項目名を入力",
          body: "Todo項目の登録画面では、その項目名を入力し、決定ボタンを押します。",
          image: Image.network(
              "https://cdn.pixabay.com/photo/2020/05/30/09/53/todo-lists-5238324_1280.jpg"),
        ),
        PageViewModel(
          title: "Todo項目の再編集も可能",
          body: "Todoリスト画面で既存のTodo項目を選択すると、その項目の再編集画面に移ります。そこで項目名の変更を行います。",
          image: Image.network(
              "https://cdn.pixabay.com/photo/2019/12/24/02/59/pen-4715847_1280.jpg"),
        ),
      ],
      next: const Text("次へ", style: TextStyle(fontWeight: FontWeight.bold)),
      done: const Text("完了", style: TextStyle(fontWeight: FontWeight.bold)),
      onDone: () => Get.to(() => const Home()),
    );
  }
}

class Home extends StatelessWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    var controller = Get.put<Controller>(Controller());

    return Scaffold(
        appBar: AppBar(title: const Text("TODOホーム"), actions: const [
          Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.info))
        ]),
        body: Container(
            padding: const EdgeInsets.all(20),
            child: Obx(() => ListView.builder(
                  itemCount: controller.mainListItems.length,
                  itemBuilder: (context, i) =>
                      makeItemCard(controller.mainListItems[i]),
                ))),
        floatingActionButton: FloatingActionButton(
          child: const Icon(Icons.add),
          onPressed: () => Get.to(() => const TodoEdit()),
        ));
  }

  Widget makeItemCard(TodoItem item) {
    return Card(
      child: ListTile(
        leading: const Icon(Icons.wb_sunny),
        title: Text(item.title),
        trailing: const Icon(Icons.more_vert),
        isThreeLine: false,
        dense: false,
        enabled: true,
        selected: false,
        onTap: () {
          Get.to(() => const TodoEdit(), arguments: {"item": item});
        },
        onLongPress: () {},
      ),
    );
  }
}

class TodoEdit extends StatelessWidget {
  const TodoEdit({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    TodoItem? item = Get.arguments?["item"];
    String itemTitle = (item == null) ? "" : item.title;

    return Scaffold(
      appBar: AppBar(
        title: const Text("TODO編集"),
      ),
      body: Column(children: [
        const Padding(padding: EdgeInsets.only(top: 50)),
        TextField(
          controller: TextEditingController(text: itemTitle),
          maxLength: 50,
          decoration:
              const InputDecoration(prefixIcon: Icon(Icons.view_list_outlined)),
          onChanged: (String t) => itemTitle = t,
        ),
        ElevatedButton(
          onPressed: () {
            makeItem(itemTitle, item);
            Get.back();
          },
          child: const Text("決定"),
        ),
      ]),
    );
  }

  void makeItem(String txt, TodoItem? item) {
    var controller = Get.find<Controller>();

    if (item == null) {
      var newItem = TodoItem(txt);
      var todoBox = Hive.box<TodoItem>(todoBoxName);

      todoBox.add(newItem);
      controller.mainListItems.add(newItem);
    } else {
      item.title = txt;
      item.save();

      controller.mainListItems.refresh();
    }
  }
}

コードのポイント

コード中の番号を付したコメントについて、解説していきます。

(1) インポート
//(1) 必要パッケージのインポート
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'todo_item.dart';
import 'app_info.dart';
import 'package:introduction_screen/introduction_screen.dart';

念の為必要なインポート部分を示しておきます。

ご覧の通りなのですが、他のファイルに分けた自前のクラスのインポートも忘れずに記載しておきます。

(2) Hiveの初期化
  //(2) Hiveの初期化
  await Hive.initFlutter();
  Hive.registerAdapter(TodoItemAdapter());
  Hive.registerAdapter(AppInfoAdapter());
  await Hive.openBox<TodoItem>(todoBoxName);
  await Hive.openBox<AppInfo>(infoBoxName);

Hiveの初期化も本論と関係ありませんが、前回のTodoのときよりもクラスが増えていますので一応乗せておきます。

ポイントはHive.registerAdapterがHive.openBoxの前、ということです。openのためにアダプタを使うためです。

ちなみにここでもしAdapterがないと言われる場合は「flutter packages run build_runner build」コマンドの実行を忘れているのかもしれません。

(3) 初回起動判定と初期画面切替
    //(3) 初回起動時かどうかを判定し、初回のみIntro()を表示
    var infoBox = Hive.box<AppInfo>(infoBoxName);
    Widget nextScreen = const Home();

    if (infoBox.isEmpty) {
      var info = AppInfo()..isFirst = false;
      infoBox.add(info);
      nextScreen = const Intro(); 
    }

まず「Hive.box」でAppInfoのボックスを取得します。しかし初回の起動ではそもそもそのボックスに何も入っていません。

この場合は、AppInfoを作成して永続化します。次にこの永続化情報が見つかってボックスが空でなければ初回実行ではないと判断できます。

nextScreenに次の画面を構成するウィジェットを入れておいて、最初にそちらに遷移させるコードになっています。nextScreenを最初にHomeにしておいて、初回起動だった場合のみ、アプリ説明画面を構成するIntroクラスが入るようにしています。

ここで一点注意ですが「Widget nextScreen = const Home()」の部分、いつもどおり

var nextScreen = const Home();

とすると「nextScreen = const Intro()」の部分で不整合が起こってしまいます。

これは「var nextScreen = const Home()」の時点で型推論がnextScreenをHome型であると判断するためです。しかし意図としてはWidgetですので、陽にWidgetとして宣言しています。(細かく言うとStatelessWidgetですけれども、そこはどちらでもOK)

Dartがvarの仕様を推奨しているのは型が明らかである場合、ですがここではHomeを代入した時点で別のWidgetを代入する意図であることは明らかではないので、その部分を陽に宣言するというわけです。そこだけ注意しておけばOKです。

(4) アプリ紹介画面の設定
  Widget build(BuildContext context) {
    //(4) イントロスクリーンの設定
    return IntroductionScreen(
      pages: [
        PageViewModel(
          title: "右下のボタンを押して新規登録",
          body: "Todo項目を新規作成するときは、右下のボタンを押すことで登録画面に移ることができます。",
          image: Image.network(
              "https://cdn.pixabay.com/photo/2015/09/21/00/54/plant-949111_1280.jpg"),
        ),
        PageViewModel(
          title: "登録画面で項目名を入力",
          body: "Todo項目の登録画面では、その項目名を入力し、決定ボタンを押します。",
          image: Image.network(
              "https://cdn.pixabay.com/photo/2020/05/30/09/53/todo-lists-5238324_1280.jpg"),
        ),
        PageViewModel(
          title: "Todo項目の再編集も可能",
          body: "Todoリスト画面で既存のTodo項目を選択すると、その項目の再編集画面に移ります。そこで項目名の変更を行います。",
          image: Image.network(
              "https://cdn.pixabay.com/photo/2019/12/24/02/59/pen-4715847_1280.jpg"),
        ),
      ],
      next: const Text("次へ", style: TextStyle(fontWeight: FontWeight.bold)),
      done: const Text("完了", style: TextStyle(fontWeight: FontWeight.bold)),
      onDone: () => Get.to(() => const Home()),
    );
  }

アプリ説明画面は IntroductionScreenのコンストラクタの記載のみです。

主要な部分はpagesに与えるPageViewModelのリストです。これを好きなだけ連ねればその分のページ数の説明が構成できます。各要素は何を記載すべきか、ほぼ明らかですよね。

   PageViewModel(
    title: "右下のボタンを押して新規登録",
    body: "Todo項目を新規作成するときは、右下のボタンを押すことで登録画面に移ることができます。",
    image: Image.network(
        "https://cdn.pixabay.com/photo/2015/09/21/00/54/plant-949111_1280.jpg"),
  ),

ここではネットワークからのダウンロード画像を使っていますが、画像の回でやったとおりローカルファイルを用意する場合は下記を御覧ください:


flutter.gakumon.jp

「onDone: () => Get.to( () => const Home() )」の記載によって、アプリ説明画面を完了したらHomeに遷移するように指定しています。ほかにも任意の処理をここに記載できますので、ここに行き着いてはじめて初回起動を完了したとする作りもありですね。






おわりに

今回はアプリの最初の部分として、スプラッシュスクリーンとアプリ説明画面についてまとめてみました。

ネイティブでとりあえずスプラッシュを出せるのは強力ですね。ちなみに「flutter_native_splash」の公式サイトではネイティブスプラッシュの表示の後(Flutterが起動した後)、Flutter上で2番目のスプラッシュスクリーンを用意して、データの読み込みや初期設定の進捗を見せていくことを推奨しています。割と二段階になっているアプリも実際に多いですよね。

アプリの使用をさらっと数画面で説明するのは、スマホアプリから始まった文化だと思いますが、ポイントを突いた説明画面があるアプリっていいですよね。実装は簡単ですけれども、内容は十分こだわって作っていきたいものです。

f:id:linkedsort:20211205015841j:plain