「来期アニメ」アプリへのチュートリアルの組み込み(tutorial_coach_mark、scrollable_positioned_list)

前回のブログでtutorial_coach_markパッケージの組み込み方法はわかったので、次は実際に「来期アニメ」への組み込み対応をしました。

いくつか追加で必要な対応があったので紹介します。

対象のウィジェットがスクロールアウトしている場合にチュートリアルが表示されない問題

一番上のアニメのハートマークにチュートリアルを設定しましたが、それがスクロールアウトした場合に、ターゲットが見つからず、チュートリアルを表示できない状態になりました。

いくつか対応案はありましたが、お手軽な方法として、チュートリアルを呼び出した時にスクロールリセットすれば、強制的にターゲットを画面内に戻せて解決できる、と思ってその方法を調べました。

ListViewの機能に普通にあるだろうと思って調べるとなさそうで、ならばと思い、AppBarをタップするとスクロールリセットかかるでそのイベントリスナを発火させれば出来るだろうと思って調べて見るとこの方法もなさそう、となり、お手軽な方法は見つけられず。。

結局、Google謹製(といってもFlutterコアチームではないらしい)の「scrollable_positioned_list」パッケージを入れることになってしまいました。

やりたいことはスクロールリセットだけなので、index単位でスクロール制御ができるこのパッケージは明らかにオーバースペックで微妙だったのですが、まぁそのうちスクロール制御したくなることもあるだろう、と自分を納得させて採用しました。

結果が以下です。
ちょっとわかりにくいですが、一番したまでスクールしたあと、?ボタンを押すと、一番上の「ひぐらし」まで一瞬でスクロールリセットした後、チュートリアルが出ているのがわかると思います。

scrollable_positioned_list 0.2.0-nullsafety.0 | Flutter Package
A list with helper methods to programmatically scroll to an item.

ページャーの追加

チュートリアルの進行度がわかるようにページャーを追加しました。

「戻る(controller.previous())」は前回確認済みでしたが、「進む(controller.next())」もあることがわかったのでそれを使っています。

TargetFocusを生成する際にkeyTargetウィジェットの存在チェック

keyTargetに指定するターゲットウィジェットが存在しない場合、エラーが出てチュートリアルが停止してしまいます。

今回の組み込みでは、Twitterアイコンが存在しない場合があったので、そこでエラーになっていました。

調べたところ、GlobalKeyは設定ウィジェットへの参照を持っていることがわかったので、以下のようにして、ターゲットウィジェットが存在する場合だけ、TargetFocusを作るようにしました。

  TargetFocus? getTargetFocus(
    int targetIndex,
    int targetNr,
    GlobalKey keyTarget,
    String title,
    String body,
  ) {
    // ターゲットウィジェットの存在チェック
    if (keyTarget.currentWidget == null) {
      return null;
    }

    //debugPrint(title);

    return TargetFocus(

まとめ

いくつかの課題はありましたが、tutorial_coach_mark、scrollable_positioned_listのおかげで無事チュートリアルを作ることができました。

自分で作るとなかなかこのおしゃれな感じは出せないので本当にありがたいパッケージです。
作成者の方に感謝いたします。

このあと実機でバグチェックしてバージョンアップ申請しようと思います。

本記事が気に入っていただけたなら、ぜひ自作アプリのインストールをお願いします。

参考にしたページ

tutorial_coach_mark | Flutter Package
Guide that helps you to present your app and its features in a beautiful, simple and customizable way.
scrollable_positioned_list 0.2.0-nullsafety.0 | Flutter Package
A list with helper methods to programmatically scroll to an item.
特定の位置までのスクロールを実現する3つのパッケージ|木藤紘介|note
はじめに FlutterではスクロールUIを実現するためにListViewやGridViewなど便利なWidgetが用意されています。実際にこれらを使うと、iOS/Androidで実装するよりも簡単なのでは?と思うくらい楽にスクロールUIを構築できてしまいます。 しかしスクロールを操作することについてはFlutte...

最後に参考として今回作ったチュートリアルクラス全文を載せて終わります。

import 'package:flutter/material.dart';
import 'package:tutorial_coach_mark/tutorial_coach_mark.dart';

// Singleton
class TutorialCoachMarkWidget {
  late TutorialCoachMark tutorialCoachMark;
  static final TutorialCoachMarkWidget instance = TutorialCoachMarkWidget._();

  List<TargetFocus> targets = <TargetFocus>[];

  final GlobalKey keyGenres = GlobalKey();
  final GlobalKey keyFavo = GlobalKey();
  final GlobalKey keyNotification = GlobalKey();
  final GlobalKey keyTwitter = GlobalKey();

  TutorialCoachMarkWidget._();

  void show(context) {
    setupTargets();

    tutorialCoachMark = TutorialCoachMark(
      context,
      targets: targets,
      colorShadow: Colors.red,
      focusAnimationDuration: Duration(milliseconds: 500),
      textSkip: "チュートリアルを終了する",
      paddingFocus: 10,
      opacityShadow: 0.8,
      onFinish: () {},
      onClickTarget: (target) {},
      onSkip: () {},
      onClickOverlay: (target) {},
    )..show();
  }

  void setupTargets() {
    /* 全てのチュートリアル対象ウィジェット */
    List<Map<String, dynamic>> allTargetParams = [
      {
        "keyTarget": keyGenres,
        "title": "タグ付け",
        "body": "タグが足りない場合は追加、間違っている場合は削除、できます。\nより良いタグ付けにご協力いただけると助かります。",
      },
      {
        "keyTarget": keyFavo,
        "title": "お気に入り",
        "body":
            "気になるアニメにハートをつけてください。\n画面右上メニューから絞り込みもできます。\n隣の数字はハートをつけている人数です。",
      },
      {
        "keyTarget": keyNotification,
        "title": "通知",
        "body":
            "ONにすると放送開始3日前の21:00に通知します。\n録画忘れ防止機能です。\n画面右上メニューから絞り込み表示もできます。",
      },
      {
        "keyTarget": keyTwitter,
        "title": "Twitter",
        "body":
            "公式推奨のハッシュタグ(#)でTwitterの口コミを表示します。\nTwitterアプリ(ない場合はブラウザ)が起動します。",
      },
    ];

    /* 画面上に表示されているチュートリアル対象ウィジェットの絞り込み */
    List<Map<String, dynamic>> targetParams = [];
    allTargetParams.forEach((element) {
      GlobalKey k = element["keyTarget"];
      if (k.currentWidget != null) {
        targetParams.add(element);
      }
    });

    targets = [];

    for (int i = 0; i < targetParams.length; i++) {
      Map<String, dynamic> tp = targetParams[i];
      var target = getTargetFocus(
          i, targetParams.length, tp["keyTarget"], tp["title"], tp["body"]);
      if (target != null) {
        targets.add(target);
      }
    }
  }

  TargetFocus? getTargetFocus(
    int targetIndex,
    int targetNr,
    GlobalKey keyTarget,
    String title,
    String body,
  ) {
    if (keyTarget.currentWidget == null) {
      return null;
    }

    //debugPrint(title);

    return TargetFocus(
      identify: "Target $targetIndex",
      keyTarget: keyTarget,
      color: Colors.black,
      contents: [
        TargetContent(
          align: ContentAlign.bottom,
          builder: (context, controller) {
            return Container(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Row(
                    mainAxisAlignment: MainAxisAlignment.start,
                    children: [
                      Icon(Icons.help_outline, color: Colors.white),
                      Text(
                        title,
                        style: TextStyle(
                            fontWeight: FontWeight.bold,
                            color: Colors.white,
                            fontSize: 20.0),
                      ),
                    ],
                  ),
                  Padding(
                    padding: const EdgeInsets.only(top: 10.0),
                    child: Text(
                      body,
                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      TextButton(
                        child: Text(
                          0 < targetIndex ? '< 前の説明に戻る' : '< 説明を終了する',
                        ),
                        style: TextButton.styleFrom(
                          primary: Colors.white,
                        ),
                        onPressed: () => controller.previous(),
                      ),
                      Text("${targetIndex + 1} / $targetNr",
                          style: TextStyle(color: Colors.white)),
                      TextButton(
                        child: Text(
                          targetIndex + 1 < targetNr
                              ? '次の説明に進む >'
                              : '説明を終了する >',
                        ),
                        style: TextButton.styleFrom(
                          primary: Colors.white,
                        ),
                        onPressed: () => controller.next(),
                      ),
                    ],
                  ),
                ],
              ),
            );
          },
        ),
      ],
    );
  }
}

コメント

  1. […] Flutterアプリでtutorial_coach_markパッケージを使ってチュートリアルを簡単に実装する「来期アニメ」アプリにチュートリアルをつけたいと思ってどんなパッケージがあるか調べました。いくつかの中から、評判が良くて表示もかっこいいtutorial_coach_markを選んで試してみました。パッケージのページにある…take424.dev2021.06.22 「来期アニメ」アプリへのチュートリアルの組み込み(tutorial_coach_mark、scr… […]

タイトルとURLをコピーしました