【Flutter】スマホアプリでよく見かけるチュートリアルページを作る方法（３つのパッケージを比較）

# 最終的にこういったチュートリアルが作れるようになります

左から、tutorial_coach_mark　flutter_sliding_tutorial flutter_overboardを使用したチュートリアルページです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/4c3a48e0-3ed4-7e19-29ce-bbc0564cb11a.gif" width="160"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/1f7c8d84-83eb-9238-cb4f-a68bfaa0b54d.gif" width="160"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/c9521353-2a95-c5bf-e2c1-699beba6e633.gif" width="160">

また、[GitHubにサンプルコードを用意しました。](https://github.com/tokku5552/flutter_tutorial_compare)
ローカルにクローンしてpub getした後にビルドすれば上記のアプリを試せます。
ぜひ自分のアプリに組み込んでみてください。

# 環境
Mac OS X 10.15.7
Windows 10 Pro
両方で動作確認済み

iOSシミュレータ：iPhone 12 Max Proで動作確認
Androidエミュレータ：APIレベル28のPixel 3aで動作確認

```
[✓] Flutter (Channel stable, 1.22.4, on Mac OS X 10.15.7 19H114 darwin-x64, locale ja-JP)
 
[✓] Android toolchain - develop for Android devices (Android SDK version 30.0.2)
[✓] Xcode - develop for iOS and macOS (Xcode 12.3)
[!] Android Studio (version 4.1)
    ✗ Flutter plugin not installed; this adds Flutter specific functionality.
    ✗ Dart plugin not installed; this adds Dart specific functionality.
[!] VS Code (version 1.52.0)
    ✗ Flutter extension not installed; install from
      https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter
[✓] Connected device (1 available)

```
※Android Studioのプラグインのパスがバージョンアップにより微妙に変わって``flutter doctor``でエラーが発生するバグがあるようです。動作には影響ないので、修正されるまで放置しています。
※VSCodeのエラーが出ていますが、VSCodeはFlutterのビルドに使用していないので無視しています。


# tutorial_coach_markを使ったチュートリアル
1つ目はtutorial_coach_markを用いた以下のようなチュートリアルを作成します。
![tutorial_coach_mark.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/4c3a48e0-3ed4-7e19-29ce-bbc0564cb11a.gif)
[公式のページ](https://pub.dev/packages/tutorial_coach_mark)

tutorial_coach_markは実際の画面に対して組み込んでいくようなチュートリアルです。
私はチュートリアル側の実装をすべてmodel側に実装しましたが、別に同じページ内で実装しても良いと思います。
ざっくりいうと、**フォーカスを当てたい箇所に``GlobalKey``を仕込んでおいて、``List<TargetFocus>``で挙動を指定する**という感じです。

```dart:tutorial_coach_mark_model.dart
class TutorialCoachMarkModel extends ChangeNotifier {
  final List<TargetFocus> targets = List();
  final GlobalKey keyButton = GlobalKey();
  final GlobalKey keyButton1 = GlobalKey();
  final GlobalKey keyButton2 = GlobalKey();

  void init() {
    targets.add(
      TargetFocus(
        identify: "Target 0",
        keyTarget: keyButton,
        contents: [
          ContentTarget(
              align: AlignContent.bottom,
              child: Container(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      "フォーカスを当ててみる",
                      style: TextStyle(
                          fontWeight: FontWeight.bold,
                          color: Colors.white,
                          fontSize: 20.0),
                    ),
                    Padding(
                      padding: const EdgeInsets.only(top: 10.0),
                      child: Text(
                        "もう一度focusを押すとfocus1にフォーカスが移ります",
                        style: TextStyle(color: Colors.white),
                      ),
                    )
                  ],
                ),
              ))
        ],
      ),
    );

```
定義したターゲットリストに``TargetFocus``を追加していきます。
``keyTarget``で``GlobalKey``を指定することによって、その要素にフォーカスが向きます。

また、以下のように、shapeに``ShapeLightFocus.RRec``を指定するとフォーカスが四角になります。

```dart:tutorial_coach_mark_model.dart
// package:tutorial_coach_mark/animated_focus_light.dart のインポートが必要
        shape: ShapeLightFocus.RRect,
        radius: 5,
```

実際にチュートリアルを表示する際は``TutorialCoachMark``に定義した``List<TargetFocus>``を渡してやります。
``target``がクリックされたときの処理と``skip``がクリックされたときの処理をそれぞれ``onClickTraget``、``onClickSkip``に定義します。
私が用意したサンプルでは``print``しているだけです。

```dart:tutorial_coach_mark_model.dart
 void showTutorial(BuildContext context) {
    TutorialCoachMark(context,
        targets: targets,
        colorShadow: Colors.red,
        textSkip: "SKIP",
        paddingFocus: 10,
        opacityShadow: 0.8, onFinish: () {
      print("finish");
    }, onClickTarget: (target) {
      print(target);
    }, onClickSkip: () {
      print("skip");
    })
      ..show();
  }
```


# flutter_sliding_tutorialを使ったチュートリアル
次にflutter_sliding_tutorialを用いたチュートリアルを紹介します。
以下のようなものが作成できます。
![flutter_sliding_tutorial.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/1f7c8d84-83eb-9238-cb4f-a68bfaa0b54d.gif)
[公式ページ](https://pub.dev/packages/flutter_sliding_tutorial)を見ていただければわかりますが、本当はもっとリッチなページが作成できます。（本当は画像やアイコンを複数配置して、かっこよくスライドさせられる）

私の例ではStatelessWidgetで実装してしまいましたが、どうやら[StatefulWidgetでないとうまく動かないようです。](https://qiita.com/kasa_le/items/ecad4f3d3f5aa81835db)

スライドして表示するページを用意し、``index``と``ValueNotifier<double> notifier``を渡しながら``AnimatedBackgroundColor``のなかの``children``要素でそれらを呼ぶ、といった感じでしょうか。
私は今回こちらを採用しなかったので、機会あればもう少し公式ドキュメント読み込んで実装してみようとおもいます。


# flutter_overboardを使ったチュートリアル
最後にflutter_overboardを使ったチュートリアルの作成です。以下のようなチュートリアルページが作成できます。
![flutter_overboard_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141823/c9521353-2a95-c5bf-e2c1-699beba6e633.gif)

[公式はこちら](https://pub.dev/packages/flutter_overboard)
こちらは細かい制御こそできないものの、かなり簡単に初回起動時に表示されるようなタイプのチュートリアルページを実装できます。

```dart:flutter_overboard_page.dart
class FlutterOverboardPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('FlutterOverboardPage'),
      ),
      body: OverBoard(
        pages: pages,
        showBullets: true,
        skipCallback: () {
          // when user select SKIP
          Navigator.pop(context);
        },
        finishCallback: () {
          // when user select NEXT
          Navigator.pop(context);
        },
      ),
    );
  }

  final pages = [
    PageModel(
        color: const Color(0xFF95cedd),
        imageAssetPath: 'assets/0.png',
        title: '文字を表示できます',
        body: '細かい説明をbodyに指定して書くことが出来ます',
        doAnimateImage: true),
    PageModel(
        color: const Color(0xFF9B90BC),
        imageAssetPath: 'assets/1.png',
        title: '左右のスワイプ',
        body: 'NEXTを押さなくても左右にスワイプすることで画面の切替が出来ます',
        doAnimateImage: true),
    PageModel.withChild(
        child: Padding(
            padding: EdgeInsets.only(bottom: 25.0),
            child: Text(
              "さあ、始めましょう",
              style: TextStyle(
                color: Colors.white,
                fontSize: 32,
              ),
            )),
        color: const Color(0xFF5886d6),
        doAnimateChild: true)
  ];
}
```

``PageModel``でページを定義して、リストで保持しておき、``OverBoard``の``pages``で渡してあげるだけです。
かなり簡単ですが、``PageModel``で指定できる要素は少なく、カスタマイズしにくいです。
``PageModel.withChild``として呼べば``child``でその他のWidgetを呼べるので色々表示しようと思えばできます。

# 比較してみて
アプリの初回起動時に1度だけ表示するチュートリアルページはflutter_overboardが使いやすかったです。
アプリ操作時のスクリーンショットを撮って、少しわかりやすく加工、テキスト追加などで済ませてしまえば、サクッと実装できそうだなと感じました。
また、tutorial_coach_markは操作が分かりづらいであろうページに個別に仕込んでおいて、AppBarのメニューなどからヘルプとして呼び出したりできるようにすると良いのかなと思いました。
新機能を追加したときなどに使い方をレクチャーするために導入するのにも良さそうです。

## おまけ①〜アセットファイル〜
Flutterでの開発時にアセットフォルダから画像などを読み込む場合は、pubspec.yamlに読み込む画像を追加する必要があるようです。（私は知りませんでした・・・）

```yaml:pubspec.yaml
flutter:
  assets:
    - assets/hello.json
    - assets/flutter-logo.png
```

## おまけ②〜初回起動時のみの表示〜
iOSであればNSUserDefaults、AndroidならSharedPreferencesを使って永続化するのが一般的かと思いますが、Flutterにも```SharedPreferences```があります。
``pubspec.yaml``に``shared_preferences``追加して``pub get``します。

- [shared_preferences | Flutter Package - Pub.dev](https://pub.dev/packages/shared_preferences)

トップ画面で```WidgetsBinding.instance.addPostFrameCallback()```を呼びます。
これによって画面描画が完了した直後にメソッドの呼び出しができます。

```dart:main.dart
class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) => _showTutorial(context));
```

呼び出したメソッドの中で```SharedPreferences```を使って初回起動かどうかチェックしています。
初回起動でチュートリアル画面の表示が終わったら、```setBool()```で初回起動フラグにtrueを渡してフラグを永続化します。

```dart:main.dart
void _showTutorial(BuildContext context) async {
    final pref = await SharedPreferences.getInstance();

    if (pref.getBool('isAlreadyFirstLaunch') != true) {
      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => FlutterOverboardPage(),
          fullscreenDialog: true,
        ),
      );
      pref.setBool('isAlreadyFirstLaunch', true);
    }
  }
```


#### 参考
- [【Flutter】アプリ開発_初心者のアプリをプロっぽくする最強のpackageを紹介](https://qiita.com/kazumaz/items/876e162cf429014661d8)
- [Flutterでチュートリアル画面を実装する](https://qiita.com/kasa_le/items/ecad4f3d3f5aa81835db)
- [Flutterでチュートリアル画面を作ろう](https://speakerdeck.com/karmactonics/flutterdetiyutoriaruhua-mian-wozuo-rou)
- [tutorial_coach_mark | Flutter Package - Pub.dev](https://pub.dev/packages/tutorial_coach_mark)
- [flutter_sliding_tutorial | Flutter Package - Pub.dev](https://pub.dev/packages/flutter_sliding_tutorial)
- [flutter_overboard | Flutter Package - Pub.dev](https://pub.dev/packages/flutter_overboard)