Flutterでチュートリアル画面を実装する

Flutterアプリの初回起動時に、チュートリアル画面を実装したのでメモ。

# 環境など
|ツールなど|バージョンなど|
|:--|:--|
| MacBook Air Early2015|macOS Mojave 10.14.5|
|Android Studio |3.6.1|
|Java |1.8.0_131|
|Flutter|1.12.13+hotfix.5|
|Dart |2.7.0|
|Xcode |10.2|

# 依存パッケージ

- [flutter_sliding_tutorial](https://pub.dev/packages/flutter_sliding_tutorial)
  - スライディングチュートリアル向けのパッケージです。スライディングページ自体はPageViewで作る必要がありますが、インジケーターとの同期を楽にしてくれます
- [shared_preferences](https://pub.dev/packages/shared_preferences)
  - 設定ファイルの読み書きパッケージ。各ネイティブの実装に一番忠実っぽいです。
  - チュートリアルを表示したかどうかのフラグを保存するのに使います。

```pubspec.yaml
dependencies:
  ...

  flutter_sliding_tutorial: ^0.1.0
  shared_preferences: ^0.5.7+1
```

`flutter pub get`を忘れずに。

# チュートリアル画像の用意

頑張って画像ファイルを作るしかありません。
私は4画面分作るのにGWの後半を費やしました(笑)

それと、インジケーター用の画像も用意した方が良いですね。36pxくらいであれば良いんじゃ無いでしょうか？

# 実装

## 1.チュートリアルページウィジェット
ウィジェットクラス`TutorialPage`を作ります。
インジケーターは`Stack`で載せるという手もあるかも知れませんが、画像に被ってしまうと困るので`Column`で下から並べ、余った領域を画像が全部使う、という設定にしました。

```dart
/// TutorialPage
class TutorialPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final ValueNotifier<double> notifier = ValueNotifier(0);
    int pageCount = 4;

    return Scaffold(
      backgroundColor: Color.fromARGB(255, 151, 212, 217),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: <Widget>[
          Expanded(
            child: SlidingTutorial(
              pageCount: pageCount,
              notifier: notifier,
            ),
          ),
          Container(
            width: double.infinity,
            height: 40,
            color: Colors.white,
            child: SlidingIndicator(
              indicatorCount: pageCount,
              notifier: notifier,
              activeIndicator: Image.asset(
                'assets/tutorial/dot1.png',
              ),
              inActiveIndicator: Image.asset(
                'assets/tutorial/dot2.png',
              ),
              margin: 8,
              sizeIndicator: 20,
            ),
          ),
        ],
      ),
    );
  }
}
```

`ValueNotifier`というのは、`ChangeNotifier`の一種で、インジケーターと`PageView`の連携に使うものです。

`SlidingIndicator`は**flutter_sliding_tutorial**パッケージのクラスです。
`SlidingTutorial`は自作のクラスで、`PageView`を持っています。
**flutter_sliding_tutorial**パッケージは`PageView`は作ってはくれないんで、そこは自前で作る必要があります。

`crossAxisAlignment: CrossAxisAlignment.end`が、下からサイズ固定のものを並べる指定です。
で、残った領域を、`Expanded`が使い切ります。

## 2.PageView

`PageView`を持つ`SlidingTutorial`ウィジェットクラスを作ります。`PageView`は`StatefulWidget`じゃないと動かないです。

```dart
/// Pager(メインのView Pager部分)
class SlidingTutorial extends StatefulWidget {
  final ValueNotifier<double> notifier;
  final int pageCount;

  SlidingTutorial({Key key, this.notifier, this.pageCount}) : super(key: key);

  @override
  State<SlidingTutorial> createState() => _TutorialState();
}

class _TutorialState extends State<SlidingTutorial> {
  final _pageController = PageController(initialPage: 0);

  @override
  void initState() {
    _pageController.addListener(_onScroll);
    super.initState();
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return PageView(
      controller: _pageController,
      children: List<Widget>.generate(
        widget.pageCount,
        (index) => _getPageByIndex(index),
      ),
    );
  }

  Widget _getPageByIndex(int index) {
    final imageName = "assets/tutorial/tutorial${index + 1}.png";
    if (index < 3) {
      return Image.asset(
          imageName,
          alignment: Alignment.center,
          fit: BoxFit.contain,
      );
    } else {
      return GestureDetector(
          onTap: () {
            _onFinishTutorial();
          }, // handle your image tap here
          child: Image.asset(
            imageName,
            key: Key("tutorialLastPage"),
            alignment: Alignment.center,
            fit: BoxFit.contain,
          ),
      );
    }
  }

  void _onScroll() {
    widget.notifier?.value = _pageController.page;
  }
```

普通の`PageView`と作り方は変わらないですが、`PageController#addListener`していて、スクロールしたとき`ValueNotifier`にも通知をしているところがインジケーターとの同期のための実装ですね。

最後のページだけ、タップを受け取りたいので`GestureDetector`で括っています。こうすると、わざわざボタンなどを配置したりしなくても、ページ全体のタップを拾うことが出来ます。

`key: Key("tutorialLastPage")`の指定で、テストの時にウィジェットを取りやすくしています。

## 3.前の画面に戻る

操作してみると分かりますが、Androidの端末の戻るボタンをタップすると、チュートリアル画面が終了してしまいます。

なので、まず`WillPopScope`で`Scaffold`全体を括ってしまいます。

```dart
      body: WillPopScope(
        onWillPop: () async => false,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: <Widget>[
            ...
          ],
        ),
      ),
    );
```

`onWillPop: () async => false,`で、戻る処理を無効にしています。これはActionBarに戻るナビゲーターアイコンが出ている場合も無効にしてくれますが、この画面はActionBar自体表示してないのであまり関係ないですね。

で、前の画面に戻る処理は、`_onFinishTutorial`でやります。

```dart
  void _onFinishTutorial() async {
    // 設定を変更する
    final preference = await SharedPreferences.getInstance();
    preference.setBool(PreferenceKey.TUTORIAL_DONE, true);
    // Topまで戻る
    Navigator.pop(context);
  }
```

## 4.チュートリアル画面への遷移キックの実装

初回起動だったらチュートリアル画面へ遷移する、というのを入れれば完成です。
トップページのルートウィジェットが`StatefulWidget`なら、`initState`でチェックしてやればいいでしょうか。
では、`StatelessWidget`にしている場合は？

`WidgetsBinding.instance.addPostFrameCallback`というのが使えます。

```dart
/// TopPage
class TopPage extends StatelessWidget {
  static const String TITLE_TOP = 'TOPタイトル';

  final String date;

  TopPage({Key key, this.date}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 画面の描画が終わったタイミングで表示させる
    WidgetsBinding.instance.addPostFrameCallback((_) => _showTutorial());
    return Scaffold(
     ...
    );
  }
}
```

こんな便利な方法があったとは！私も初めて知りました^^;

`_showTutorial`内では、Preferenceの値をチェックして、起動するかどうか決めています。

```dart
  void _showTutorial() async {
    final preference = await SharedPreferences.getInstance();
    // 最初の起動ならチュートリアル表示
    if (preference.getBool(PreferenceKey.TUTORIAL_DONE) != true) {
      // ルート遷移
      // ※まだProviderは使えないのでここでやる
      final NavigationService _navigationService = locator<NavigationService>();
      _navigationService.navigateTo('/tutorial');
    }
  }
```

**Providerパターン**を使ったMVVMのViewModelで画面遷移コードを書きたかったので、ナビゲーター呼び出しで`Context`を不要にする`NavigationService`というのを作って使っていましたが(詳細は[こちら参照](https://qiita.com/kasa_le/items/8c80ae565186c9ac275a))、このタイミングではまだProviderにアクセスできません。なのでやむを得ず、ここだけ直接ウィジェットクラス内で呼んでしまっています。無念・・・

そういう面倒くさいことをしていない人は、ここで普通に

```dart
  Navigator.of(context).pushNamed('/tutorial');
```

とすれば良いでしょう。

# 完成イメージ

画像は勿論ダミーです。

|![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/a0566fb9-56e4-2052-630e-ebfb6fff343c.gif)|
|:--|


# テスト

今回は、**shared_preferences**パッケージがモック値設定の関数を作ってくれているので、それを利用します。
https://pub.dev/packages/shared_preferences#testing

## 1.既存テストへの影響
既存のテストでは、チュートリアルを表示しようとしないようにするため、先ほどの**shared_preferences**のモック値設定を利用して、既に表示済みにしておきます。

```dart
    setUpAll(() async {
      // チュートリアル表示済みにして、表示されないようにする
      SharedPreferences.setMockInitialValues(
          {PreferenceKey.TUTORIAL_DONE: true});
    });
```

## 2.チュートリアル画面のテスト

### (1)チュートリアルウィジェット単体のテスト

あまり細かいことをやると、`PageView`や**flutter_sliding_tutorial**パッケージのテストになってしまうので、表示されている画像が正しいこと、スワイプできること、ページ数が正しいこと、くらいのテストにします。

```dart
/// TutorialPageのテスト
void main() {
  final testViewPortSize = Size(1080, 1776);

  final TestWidgetsFlutterBinding binding =
      TestWidgetsFlutterBinding.ensureInitialized();

  /// テスト用の起動(ウィジェット単体テスト用)
  Widget testTopPage() {
    return TestApp(TutorialPage());
  }

  group('TutorialPageのテスト', () {
    setUpAll(() async {
      testSetupLocator(
          analyticsService: MockAnalyticsService(),
          versionCheckService: FalseMockVersionCheckService());
    });

   testWidgets('SlidingTutorialのチェック', (WidgetTester tester) async {
      await binding.setSurfaceSize(testViewPortSize);
      await tester.pumpWidget(testTopPage());

      await tester.pumpAndSettle();

      WidgetPredicate predicate =
          (Widget widget) => widget is SlidingTutorial && widget.pageCount == 4;

      expect(find.byWidgetPredicate(predicate), findsOneWidget);
    });

    testWidgets('SlidingIndicatorのチェック', (WidgetTester tester) async {
      await binding.setSurfaceSize(testViewPortSize);
      await tester.pumpWidget(testTopPage());

      await tester.pumpAndSettle(); // ローディング終了を待つ

      expect(find.byType(SlidingIndicator), findsOneWidget);

      // アクティブなインジケーター数1
      expect(findByAssetImage('assets/tutorial/dot1.png'), findsOneWidget);
      // 非アクティブなインジケーター数(アクティブなのも裏にいるらしいので4)
      expect(findByAssetImage('assets/tutorial/dot2.png'), findsNWidgets(4));
    });

    testWidgets('PageViewのチェック', (WidgetTester tester) async {
      await binding.setSurfaceSize(testViewPortSize);
      await tester.pumpWidget(testTopPage());

      await tester.pumpAndSettle(); // ローディング終了を待つ

      expect(find.byType(PageView), findsOneWidget);
    });

    testWidgets('右にスワイプできる', (WidgetTester tester) async {
      await binding.setSurfaceSize(testViewPortSize);
      await tester.pumpWidget(testTopPage());

      await tester.pumpAndSettle(); // ローディング終了を待つ
      // 最初のページの画像
      expect(findByAssetImage('assets/tutorial/tutorial1.png'), findsOneWidget);
      await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
      // 次のページの画像
      expect(findByAssetImage('assets/tutorial/tutorial2.png'), findsOneWidget);
    });
```

`testViewPortSize`と`TestWidgetsFlutterBinding`は、Flutterのウィジェットテストではデフォルトで横長の画面になるため(恐らくWebブラウザ用の設定)、それを縦長に指定するために使います。

`TestApp`は、指定のウィジェットを`MaterialApp`ウィジェット配下で直接起動する為に作ったもので、以下のようなコードです。この中でリポジトリクラスのモック化などもやってしまっています。

```TestApp.dart
class TestApp extends StatelessWidget {
  TestApp(this.widget);

  final Widget widget;

  @override
  Widget build(BuildContext context) {
    return Provider<RecordRepository>(
      create: (context) => MockRecordRepository(),
      dispose: (context, bloc) => bloc.dispose(),
      child: MaterialApp(
        // 日本語フォント設定
        localizationsDelegates: [
          GlobalMaterialLocalizations.delegate,
          GlobalWidgetsLocalizations.delegate,
          GlobalCupertinoLocalizations.delegate,
        ],
        supportedLocales: [
          Locale('ja', ''), // Japanese
        ],
        home: widget,
      ),
    );
  }
}
```

`findByAssetImage`は以前[こちらの記事](https://qiita.com/kasa_le/items/9ab139f11bce1ce24235)で紹介した関数です。

```dart
/// 指定のpathを表示しているAssetImageを見つける
Finder findByAssetImage(String path) {
  final finder = find.byWidgetPredicate((Widget widget) {
    if (widget is Image && widget.image is AssetImage) {
      final assetImage = widget.image as AssetImage;
      return assetImage.keyName == path;
    }
    return false;
  });
  return finder;
}
```

後は、似たようなテストを追加して、最後のページまで表示されている画像が正しいか確認すれば良いでしょう。

### (2)最終ページの遷移のテスト

最終ページではタップしてTopページに戻れること、最終ページ以外では逆にタップしても何も起きないこと、を確認します。
なお、TOPページに戻れないと行けないので、このテストは、TOPページ画面から起動するようにします。

```dart
  group('画面遷移のテスト', () {
     setUp(() {
      // チュートリアルが表示されるようにする
      SharedPreferences.setMockInitialValues(
          {PreferenceKey.TUTORIAL_DONE: false});
    });

    testWidgets('途中ページではタップしても何も起きない', (WidgetTester tester) async {
      await binding.setSurfaceSize(testViewPortSize);

      await tester.pumpWidget(testMyApp('2020-02-02'));

      await tester.pumpAndSettle(); // ローディング終了を待つ

      expect(find.text(TopPage.TITLE_TOP), findsNothing);

      // タップ
      var finder = findByAssetImage('assets/tutorial/tutorial1.png');
      await tester.tap(finder);
      expect(find.text(TopPage.TITLE_TOP), findsNothing);

      // 次ページまでスワイプ
      await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
      await tester.pumpAndSettle();
      // タップ
      finder = findByAssetImage('assets/tutorial/tutorial2.png');
      await tester.tap(finder);
      await tester.pumpAndSettle();

      expect(find.text(TopPage.TITLE_TOP), findsNothing);

      // 次ページまでスワイプ
      await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
      await tester.pumpAndSettle();
      // タップ
      finder = findByAssetImage('assets/tutorial/tutorial3.png');
      await tester.tap(finder);
      await tester.pumpAndSettle();

      expect(find.text(TopPage.TITLE_TOP), findsNothing);
    });

    testWidgets('最終ページまで遷移してタップ', (WidgetTester tester) async {
      await binding.setSurfaceSize(testViewPortSize);

      await tester.pumpWidget(testMyApp('2020-02-02'));

      await tester.pumpAndSettle(); // ローディング終了を待つ

      expect(find.text(TopPage.TITLE_TOP), findsNothing);

      // 最後のページまでスワイプ
      await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
      await tester.pumpAndSettle();
      await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
      await tester.pumpAndSettle();
      await tester.fling(find.byType(PageView), Offset(-500.0, 0.0), 300);
      await tester.pumpAndSettle();

      // タップ
      await tester.tap(find.byKey(Key('tutorialLastPage')));
      await tester.pumpAndSettle();

      expect(find.text(TopPage.TITLE_TOP), findsOneWidget);
    });
  });

```

`setUp`関数で、1テスト毎に毎回Preference値のモック値を設定しています。

`find.byKey(Key('tutorialLastPage'))`で指定しているKeyは、チュートリアル画面の最終ページの`Image`にだけ設定しておいた、**Key**です。

`testMyApp`が、**Topページから起動する**ための関数で、次のようになっています。

```dart
  /// テスト用のアプリトップウィジェットから起動(画面遷移のテスト用)
  /// Topページに戻るテストがあるので、アプリのルートから起動が必要
  Widget testMyApp(String date) {
    return Provider<RecordRepository>(
        create: (context) => MockRecordRepository(),
        dispose: (context, bloc) => bloc.dispose(),
        child: MyApp(date: date));
  }
```


## 3.driveテスト

CIで回しながらスクショを撮るため、driveテストにも追加しました。
チュートリアル画面表示→通常画面表示という流れで構わないので、Prefrernceの値は`false`にします。

```app.dart
void main() {
  // チュートリアル表示からやる
  // ignore: invalid_use_of_visible_for_testing_member
  SharedPreferences.setMockInitialValues({PreferenceKey.TUTORIAL_DONE: false});

  enableFlutterDriverExtension();
  testMain(MockVersionCheckService());
}
```

```app_test.dart
void main() {
  group('App Driver Test', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    tearDownAll(() async {
      if (driver != null) {
        driver.close();
      }
    });

    Future<void> _takeScreenShot(String filename) async {
      await driver.waitUntilNoTransientCallbacks();
      final pixels = await driver.screenshot();

      final file = File('./test_driver/screenshots/$filename.png');
      await file.writeAsBytes(pixels);
      print('wrote $file');
    }

    test('チュートリアル1', () async {
      final health = await driver.checkHealth();
      print(health.status);

      await _takeScreenShot('tutorial1');
    });

    test('チュートリアル2', () async {
      final health = await driver.checkHealth();
      print(health.status);
      final finder = find.byType('PageView');
      await driver.scroll(finder, -500, 0, Duration(milliseconds: 200));
      await driver.waitFor(finder);

      await _takeScreenShot('tutorial2');
    });

    test('チュートリアル3', () async {
      final health = await driver.checkHealth();
      print(health.status);
      final finder = find.byType('PageView');
      await driver.scroll(finder, -500, 0, Duration(milliseconds: 200));
      await driver.waitFor(finder);

      await _takeScreenShot('tutorial3');
    });

    test('チュートリアル4', () async {
      final health = await driver.checkHealth();
      print(health.status);
      final finder = find.byType('PageView');
      await driver.scroll(finder, -500, 0, Duration(milliseconds: 200));
      await driver.waitFor(finder);

      await _takeScreenShot('tutorial4');
    });

    test('起動TOP画面', () async {
      final health = await driver.checkHealth();
      print(health.status);

      // チュートリアル終了画面でタップ
      await driver.tap(find.byValueKey('tutorialLastPage'));

      await _takeScreenShot('top');
    });

    ...

  });
```

`driver.tap(find.byValueKey('tutorialLastPage'))`で、最終ページの`Image`にセットしたKey名をここでも使っています。

各テスト関数が、前のテストの状況を引き継いでいるのがお分かりでしょうか？
このように、Flutterのdriveテストでは、いわゆる「シナリオ」をちゃんと考えたテストが必要になります。



# 参考サイト

【Flutter】アプリ開発_初心者のアプリをプロっぽくする最強のpackageを紹介
https://qiita.com/kazumaz/items/876e162cf429014661d8

FlutterのNavigatorで画面遷移
https://qiita.com/granoeste/items/19c119ffc36a016e6223

Flutterでチュートリアル画面を作ろう
https://speakerdeck.com/karmactonics/flutterdetiyutoriaruhua-mian-wozuo-rou