【Flutter】tutorial_coach_mark の使い方

##  初めに
tutorial_coach_mark とは、以下のように、特定の Widget にフォーカスして、ユーザにアプリの操作方法を理解してもらうためのチュートリアルを作成するパッケージです。
<img width=250, src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/a7275585-481d-3cd1-73b8-6bdf86551b54.gif">
[公式ページ](https://pub.dev/packages/tutorial_coach_mark)から引用

## 記事の対象者
+ アプリにチュートリアルを実装したい方
+ 操作が難しいアプリを開発している方
+ アプリに対して「操作がわかりにくい」とフィードバックがあった方

## 準備

### パッケージの追加
まずは[ tutorial_coach_mark パッケージ ](https://pub.dev/packages/tutorial_coach_mark/install)を「 pubspeck.yaml 」に記述します。
パッケージのバージョンは、特に制約がなければ最新のバージョンで問題ありません。

```yaml: pubspeck.yaml
dependencies:
  flutter:
    sdk: flutter

  tutorial_coach_mark: ^1.2.4
```
Pub get をしてパッケージの準備は完了です。
<br>

## 完成イメージ
![tutorial_coach_mark.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/77d95dfb-4a1c-e519-3f75-4754251b7d69.gif)

簡単な入力フォームを作成し、その入力方法を説明するためのチュートリアルを作成します。
本来であれば、入力フォームにチュートリアルを設けることはないかと思いますが、今回は一例として実装します。

## 全体コード
```dart: tutorial_coach_mark_example.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tutorial_coach_mark/tutorial_coach_mark.dart';

class TutorialCoachMarkExample extends ConsumerStatefulWidget {
  const TutorialCoachMarkExample({Key? key}) : super(key: key);

  @override
  TutorialCoachMarkExampleState createState() =>
      TutorialCoachMarkExampleState();
}

class TutorialCoachMarkExampleState extends ConsumerState<TutorialCoachMarkExample> {
  late TutorialCoachMark tutorialCoachMark;
  List<TargetFocus> targets = [];

  final GlobalKey _key = GlobalKey();
  final GlobalKey _key1 = GlobalKey();
  final GlobalKey _key2 = GlobalKey();
  final GlobalKey _key3 = GlobalKey();

  @override
  void initState() {
    initTargets();
    WidgetsBinding.instance.addPostFrameCallback(_layout);
    super.initState();
  }

  void _layout(_) {
    Future.delayed(const Duration(milliseconds: 100));
    showTutorial();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: const Text("新規登録"),
      ),
      body: Center(
        child: Column(
          children: [
            const SizedBox(
              height: 30,
            ),
            Image.asset(
              "assets/flutter_logo.png",
            ),
            const SizedBox(
              height: 30,
            ),
            const Text("登録を完了させましょう！"),
            const SizedBox(
              height: 30,
            ),
            SizedBox(
              key: _key,
              width: MediaQuery.of(context).size.width * 0.8,
              child: const TextField(
                decoration: InputDecoration(
                    border: OutlineInputBorder(), hintText: "ニックネーム"),
              ),
            ),
            const SizedBox(
              height: 30,
            ),
            SizedBox(
              key: _key1,
              width: MediaQuery.of(context).size.width * 0.8,
              child: const TextField(
                decoration: InputDecoration(
                    border: OutlineInputBorder(), hintText: "メールアドレス"),
              ),
            ),
            const SizedBox(
              height: 30,
            ),
            SizedBox(
              key: _key2,
              width: MediaQuery.of(context).size.width * 0.8,
              child: const TextField(
                obscureText: true,
                decoration: InputDecoration(
                  border: OutlineInputBorder(),
                  hintText: "パスワード",
                ),
              ),
            ),
            const SizedBox(
              height: 30,
            ),
            ElevatedButton(
              key: _key3,
              style: ElevatedButton.styleFrom(primary: Colors.blue[40]),
              onPressed: () {},
              child: const Padding(
                padding: EdgeInsets.all(10.0),
                child: Text("登録"),
              ),
            ),
          ],
        ),
      ),
    );
  }

  void initTargets() {
    targets.add(
      TargetFocus(
        identify: "Target 0",
        keyTarget: _key,
        contents: [
          TargetContent(
            align: ContentAlign.bottom,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: const <Widget>[
                Text(
                  "ニックネームを入力してください。",
                  style: TextStyle(color: Colors.white, fontSize: 18.0),
                ),
              ],
            ),
          )
        ],
        shape: ShapeLightFocus.RRect,
        radius: 5,
      ),
    );
    targets.add(
      TargetFocus(
        identify: "Target 1",
        keyTarget: _key1,
        contents: [
          TargetContent(
              align: ContentAlign.bottom,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.center,
                mainAxisAlignment: MainAxisAlignment.center,
                children: const <Widget>[
                  Text(
                    "メールアドレスを入力してください。",
                    style: TextStyle(color: Colors.white, fontSize: 18.0),
                  ),
                ],
              ))
        ],
        shape: ShapeLightFocus.RRect,
        radius: 5,
      ),
    );

    targets.add(TargetFocus(
      identify: "Target 2",
      keyTarget: _key2,
      contents: [
        TargetContent(
            align: ContentAlign.top,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: const <Widget>[
                Padding(
                  padding: EdgeInsets.only(bottom: 20.0),
                  child: Text(
                    "パスワードを入力してください。",
                    style: TextStyle(color: Colors.white, fontSize: 18.0),
                  ),
                ),
              ],
            )),
        TargetContent(
            align: ContentAlign.bottom,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: const <Widget>[
                Padding(
                  padding: EdgeInsets.only(bottom: 20.0),
                  child: Text(
                    "パスワードは安全に保管してください。",
                    style: TextStyle(color: Colors.white, fontSize: 14.0),
                  ),
                ),
              ],
            ),
        ),
      ],
      shape: ShapeLightFocus.RRect,
      radius: 5,
    ));

    targets.add(TargetFocus(
      identify: "Target 3",
      keyTarget: _key3,
      // color: Colors.red,
      contents: [
        TargetContent(
            align: ContentAlign.top,
            child: Column(
              children: <Widget>[
                Padding(
                  padding: const EdgeInsets.all(10.0),
                  child: Image.asset(
                    "assets/icon_flutter.png",
                    fit: BoxFit.contain,
                    height: 120,
                  ),
                ),
                const Padding(
                  padding: EdgeInsets.symmetric(vertical: 20.0),
                  child: Text(
                    "三つの項目が入力できたらこのボタンを押して登録完了です！",
                    style: TextStyle(color: Colors.white, fontSize: 18.0),
                  ),
                ),
              ],
            ))
      ],
      shape: ShapeLightFocus.RRect,
      radius: 10
    ));
  }

  void showTutorial() {
    tutorialCoachMark = TutorialCoachMark(
      targets: targets,
      textSkip: "SKIP",
      paddingFocus: 10,
      opacityShadow: 0.8,
    )..show(context: context);
  }
}
```

## 実装
それぞれ解説していきます。なお、今回は tutorial_coach_mark の使い方を解説することがこの記事の趣旨であるため、フォームの実装方法については解説しません。
<br>

```dart: tutorial_coach_mark_example.dart
  List<TargetFocus> targets = [];

  final GlobalKey _key = GlobalKey();
  final GlobalKey _key1 = GlobalKey();
  final GlobalKey _key2 = GlobalKey();
  final GlobalKey _key3 = GlobalKey();
```
このコードでは `TargetFocus` のリストを `targets` という変数に代入し、初期値を空のリストにしています。

また、 `_key` から `_key4` までの変数にグローバルキーを代入しています。このグローバルキーはチュートリアルでどの要素にフォーカスするかを指定する時に必要になります。
<br>

```dart: tutorial_coach_mark_example.dart
  @override
  void initState() {
    initTargets();
    WidgetsBinding.instance.addPostFrameCallback(_layout);
    super.initState();
  }
```
このコードでは `initState()` でページの初期状態を指定しています。
`initTargets()` は、先ほど作成した `TargetFocus` の空のリストに `TargetFocus` を追加していく処理になります。詳しくは後述します。

`WidgetsBinding.instance.addPostFrameCallback()`
 この関数は全ての Widget のビルドが終わった時に実行する処理を記述するための関数です。
そしてこの関数は最初にページが読み込まれたとき、一度しか発火しません。
したがって、ページが読み込まれた瞬間にチュートリアルを実行する必要がある際に最適なメソッドと言えます。

上のコードでは、`addPostFrameCallback()` 関数の引数に `_layout` が入れられています。
この `_layout` は自作のメソッドで、チュートリアルを表示させるメソッドが含まれています。
<br>

```dart: tutorial_coach_mark_example.dart
  void _layout(_) {
    Future.delayed(const Duration(milliseconds: 100));
    showTutorial();
  }
```
このコードは先述した `_layout()` の内容を記述したコードです。
`Future.delayed()` メソッドではチュートリアルを表示させる時間を遅らせています。

`showTutorial()` メソッドは、チュートリアルを表示させるメソッドで、全ての Widget のビルドが完了し、`_layout()` メソッドが実行された時に実行されます。
詳しい内容は後述します。
<br>


```dart: tutorial_coach_mark_example.dart
SizedBox(
  key: _key,
  width: MediaQuery.of(context).size.width * 0.8,
  child: const TextField(
    decoration: InputDecoration(
      border: OutlineInputBorder(), hintText: "ニックネーム"
    ),
  ),
),
```
このコードでは `SizedBox` の子要素に `TextField` を指定することで大きさを調整して表示させています。

`key: _key,`
このようにすることで、 `SizedBox` にグローバルキーが割り当てられます。
このキーがあることで、それぞれのチュートリアルの場面で異なる Widget にフォーカスを当てたり、表示させる内容を変更したりすることができるようになります。

このコード以下の `SizedBox` にそれぞれ別のグローバルキーを割り当てることで、独自の内容を表示させています。グローバルキーの割り当ては全く同じなので、その他の要素に関しては省略します。
<br>

```dart: tutorial_coach_mark_example.dart
  void initTargets() {
    targets.add(
      TargetFocus(
        identify: "Target 0",
        keyTarget: _key,
        contents: [
          TargetContent(
            align: ContentAlign.bottom,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: const <Widget>[
                Text(
                  "ニックネームを入力してください。",
                  style: TextStyle(color: Colors.white, fontSize: 18.0),
                ),
              ],
            ),
          )
        ],
        shape: ShapeLightFocus.RRect,
        radius: 5,
      ),
    );
```
このコードでは `targts` のリストに `TargetFocus` を追加しています。
以下で詳しく解説していきます。
<br>

```dart: tutorial_coach_mark_example.dart
identify: "Target 0",
```
`identify` は `TargetFocus` がどの要素に当てられているものか、何を説明するためのものかなど、 `TargetFocus` の説明などを String 型で指定するためのプロパティです。

公式ページでも使用方法が厳しく限定されているわけではないため、 `TargetFocus` の説明を簡潔にまとめれば自分にもわかりやすくなります。
<br>

```dart: tutorial_coach_mark_example.dart
keyTarget: _key,
```
このコードでフォーカスさせたい要素のグローバルキーを指定しています。
`keyTarget` がないとフォーカスする場所がないため、エラーになります。
<br>

```dart: tutorial_coach_mark_example.dart
contents: [
  TargetContent(
    align: ContentAlign.bottom,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: const <Widget>[
        Text(
          "ニックネームを入力してください。",
          style: TextStyle(color: Colors.white, fontSize: 18.0),
        ),
      ],
    ),
  )
],
```
`contents` ではチュートリアルで、特定の Widget にフォーカスが当たった際に表示される要素を指定しています。
表示させる要素は `TargetContent()` で囲む必要があります。
`align` でコンテンツを表示させる位置を指定します。
`child` で表示させる Widget を指定します。上のコードではテキストを表示させています。
テキスト以外にも画像などを表示させることも可能です。
<br>

```dart: tutorial_coach_mark_example.dart
shape: ShapeLightFocus.RRect,
radius: 5,
```
このコードではフォーカスの形を指定しています。
上のようにすると四角のフォーカスが当たります。

`shape: ShapeLightFocus.Circle,`
このようにすると円形のフォーカスが当たります。

`radius` では、フォーカスを四角にしたときのみ、その角を調整することができます。
<br>

```diff_dart: tutorial_coach_mark_example.dart
  void initTargets() {
    targets.add(
      TargetFocus(
        identify: "Target 0",
        keyTarget: _key,
        contents: [
          TargetContent(
            align: ContentAlign.bottom,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: const <Widget>[
                Text(
                  "ニックネームを入力してください。",
                  style: TextStyle(color: Colors.white, fontSize: 18.0),
                ),
              ],
            ),
          )
        ],
        shape: ShapeLightFocus.RRect,
        radius: 5,
+       color: Colors.red,
      ),
    );
```
`color` を指定すると、以下のように特定のフォーカスだけ背景色を変更することができます。
![tutorial_coach_nark_red.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/22aa06fc-c6c2-1c44-d301-4fc1b475dea8.gif)
<br>

```diff_dart: tutorial_coach_mark_example.dart
    targets.add(TargetFocus(
      // identify: "Target 2",
      keyTarget: _key2,
      contents: [
        TargetContent(
            align: ContentAlign.top,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: const <Widget>[
                Padding(
                  padding: EdgeInsets.only(bottom: 20.0),
                  child: Text(
                    "パスワードを入力してください。",
                    style: TextStyle(color: Colors.white, fontSize: 18.0),
                  ),
                ),
              ],
            )),
        TargetContent(
            align: ContentAlign.bottom,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: const <Widget>[
                Padding(
                  padding: EdgeInsets.only(bottom: 20.0),
                  child: Text(
                    "パスワードは安全に保管してください。",
                    style: TextStyle(color: Colors.white, fontSize: 14.0),
                  ),
                ),
              ],
            ),
        ),
      ],
      shape: ShapeLightFocus.RRect,
      radius: 5,
+     focusAnimationDuration: Duration(seconds: 2),
    ));
```
`focusAnimationDuration` の `Duration` を２秒に指定すると、２秒かけてフォーカスするようになるため、他の要素へのフォーカスと比較して以下のようにフォーカスする際の速度が遅くなります。
![duartion_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/811b7ca6-526c-8740-4059-d1addf6e4600.gif)
パスワードの TextField にフォーカスする際にのみフォーカスの速度が遅くなっているのが分かるかと思います。
<br>

```dart: tutorial_coach_mark_example.dart
  void showTutorial() {
    tutorialCoachMark = TutorialCoachMark(
      targets: targets,
      textSkip: "SKIP",
      paddingFocus: 10,
      opacityShadow: 0.8,
    )..show(context: context);
  }
```
このコードはチュートリアルを表示させるためのメソッドを記述しています。

`targets: targets,`
この記述で、チュートリアルのターゲットに `TargetFocus` のリストが代入された `targets` が指定されています。

`textSkip: "SKIP",`
この記述で、スキップボタンのテキストを変更しています。
なお、何も指定していないデフォルトの状態では「SKIP」というテキストになります。

`paddingFocus: 10,`
この記述で特定の要素にフォーカスした際のその要素との空白を指定しています。以下の画像では薄い赤色の部分になります。
<img width=250, src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/642cc002-6cdb-59bf-9fb9-975e616306cb.png">

`opacityShadow: 0.8,`
この記述でフォーカスされている部分以外の透明度を指定しています。
この値を変更すると以下のようになります。

opacityShadow: 0.8,
<img width=250, src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/09e6c113-50ff-1f2e-a91e-2c785b594b24.png">
<br>

opacityShadow: 0.3,

<img width=250, src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2609901/87ff9553-d5eb-8953-877d-14e92dc2ef47.png">
<br>

`TutorialCoachMark()` には `show()` メソッドがあり、これを実行することでチュートリアルが表示されます。

以上です。

## あとがき
最後まで読んでいただきありがとうございました。

参考にしていただければ幸いです。
誤っている箇所があればご指摘いただければ幸いです。

### 参考にしたサイト
https://pub.dev/packages/tutorial_coach_mark

https://medium.flutterdevs.com/tutorial-coach-mark-in-flutter-5e0a9102497b