Flutter/Navigator遷移にBuildContextを不要にする/遷移のWidgetTest

# 環境

訳あって最新版を使っていません(macをCatalinaにアップデートできない==Xcodeの最新版を入れられないので)。
従って、バージョン違いによる不動作などあるかも知れません。お気づきの点があったら是非コメント下さい。

```console
$ flutter --version
Flutter 1.12.13+hotfix.5 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 27321ebbad (4 months ago) • 2019-12-10 18:15:01 -0800
Engine • revision 2994f7e1e6
Tools • Dart 2.7.0
```

# 1. Navigatorで遷移するときにBuildContextを不要にしたい

## (1)通常の書き方
通常、Naviatorでの画面遷移はこのように書くと思います。

```Dart
Navigator.push(
      context,
      MaterialPageRoute(
        builder: (BuildContext context) => NextPage(),
      ),
    );
```

あるいは、MaterialAppでこんな風に指定しておいて、

```Dart
   return MaterialApp(
      // Navigator
      routes: {
        '/': (context) => HomePage(),
        '/next': (context) => NextPage(),
      },
      initialRoute: "/",
```

遷移時には、こう書くことも出来ます。

```Dart
   Navigator.pushNamed(context, '/next');
```

基本的にはこれで事足ります。

## (2)問題点

ソースコードを見返しているときに、次のことに気付きました。

- 「遷移のコードどこ行った？」

ページのウィジェット構成が深いネストになっていて、かつ、子ウィジェットをクラス化して作っていて、さらにそれがページのルートのコードがあるファイルとは分かれている。

そんな状況で、遷移のコードを探してファイルをウロウロすることが、よくあったのです。

検索掛ければ良いのでしょうが、`Navigator`と打つのも面倒です。

なんとかわかりやすい場所に置けないかな？と思ったのです。
そこで、

- 「ViewModelクラスを作っているから、それを介して遷移させられないか？」

と考えました。

こんな形です。

```Dart
class SubModule extends StatelessWidget{
   ...
   onTap: () => viewModel.navigateNextPage();
}

class MainViewModel with ChangeNotifier{
  void navigateNextPage(){
    // 遷移の処理
  }
}
```

## (3)BuildContextが邪魔！

しかし、遷移処理(push)呼び出し時に、`BuildContext`が必要です。
`ViewModel`クラスに`Context`を持たせる/渡すのは、恐らくMVVM的にアンチパターンです。

あ、申し遅れましたが、[こちら](https://friegen.xyz/fultter-provider-android-viewmodel-livedata/?fbclid=IwAR0dyobfqxAu5WJ9pMouITNk8XpNDFMSkLSLTvzlc63VsIGyaUkjS1goRKo)を参考に、AndroidのAAC的なMVVMパターンでアプリを設計しています(いまさら)。


- 「BuildContextが要らないNavigatorの使い方ないかなあ」

ググっていたら、見つけました。そのままのタイトルです。

Navigate Without BuildContext in Flutter using a Navigation Service
https://www.filledstacks.com/post/navigate-without-build-context-in-flutter-using-a-navigation-service/

もう、嬉しいので、このままの手法で行くことにします。

## (4)ライブラリを追加

[get_it](https://pub.dev/packages/get_it)ライブラリを使うようなので、`pubspec.yaml`に追加します。

```pubspec.yaml
dependencies:
  ....
  get_it: ^4.0.1
```

**package get**しておいて下さいね。

## (5) NavigationServiceを実装して使う

もうあとは上記のサイトのステップ通りですが、一応コードを載せていきます。

### a.NavigationServiceを実装する

普通にクラスを作ります。

```NavigationService.dart
import 'package:flutter/material.dart';

class NavigationService {
  final GlobalKey<NavigatorState> navigatorKey =
      new GlobalKey<NavigatorState>();

  Future<dynamic> navigateTo(String routeName) {
    return navigatorKey.currentState.pushNamed(routeName);
  }

  bool goBack() {
    return navigatorKey.currentState.pop();
  }
}
```

### b.locatorを宣言してsetupメソッドを作る

場所はどこでもいいと思います。参考サイトでは`locator.dart`を作っていますが、私は~~面倒なので~~`main.dart`に含めてしまいました。

```dart
import 'package:get_it/get_it.dart';

GetIt locator = GetIt.instance;

void setupLocator() {
  locator.registerLazySingleton(() => NavigationService());
}
```

### c.setupLocatorをmain関数で呼び出す

最初にプロジェクトを作ったら、恐らく以降忘れ去られているであろう、`main`関数を探します(笑)

```dart
void main() {
  setupLocator();
  runApp(MyApp());
}
```

### d.ViewModelに遷移関数を作る

```MainViewModel.dart
void navigateNextPage() {
    final NavigationService _navigationService = locator<NavigationService>();
    _navigationService.navigateTo('/input');
  }
```

`_navigationService`は、参考サイトではメンバにしていましたが、何となくローカル変数にしました。

### e.ウィジェットのコードを変更する

プロパティ`key`を使っていますが、これはテストでウィジェットを探せるようにするためです。探しやすいウィジェットならこれは不要ですが、あっても害はないはず。
今回、GridViewの中のセル用のウィジェットを探すため、`index`をKeyにしました。

```dart
InkWell(
  key: Key(index.toString()),
  onTap: () => viewModel.navigateNextPage(),
  child: ...
),
```

### f.MaterialAppのルート設定をする

こちらの書き方になっていない場合は変更します。この指定方法じゃ無いとNavigatorServiceが動きません。


**追記:** `navigatorKey`の追加も忘れないで下さい。

```dart
   return MaterialApp(
      // Navigator
      navigatorKey: locator<NavigationService>().navigatorKey,
      routes: {
        '/': (context) => HomePage(),
        '/next': (context) => NextPage(),
      },
      initialRoute: "/",
```

`home`指定は消して下さい。

ここまでやれば、起動して動作確認が出来ます。

これで、

- どんなページがあるか一覧で見やすくなる
  - `MaterialApp`のルート設定により
- 遷移のコードが見つけやすくなる
  - ViewModelクラスに遷移そのもののコードがあることにより

と、色々探しやすくするという目的が達成できました。
ページの数が増えて、ファイル数が増えて、さらに多人数で開発することになったりしたら、こういう風に決まっているとみんなやりやすいんじゃないかな。

# 2. Navigator遷移のWidgetTest

さて、ついでなのでテストも書きます。
画面を実際には表示しないWidgetTestで出来ます。

```dart
    testWidgets('セルのタップで次画面に遷移', (WidgetTester tester) async {
      setupLocator();
      await binding.setSurfaceSize(Size(1080, 1776));

      // アプリ全体を起動(Navigator設定が必要なため)
      await tester.pumpWidget(MyApp());

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

      await tester.tap(find.byKey(Key('10')));
      await tester.pumpAndSettle(); // 遷移を待つ

      expect(find.byType(NextPage), findsOneWidget);
      expect(find.text('次のページタイトル'), findsOneWidget);
    });
```

特に難しいことは無いですね。`pumpAndSettle`を駆使すれば良いだけです。
後は、ウィジェットを探すのに、`byKey`を使っているのが、ちょっと特殊です。
普通のボタンとかなら、もっと探しやすいと思いますが、ボタンが複数あるような画面だと、やはり`key`プロパティを付けるのがやりやすいんじゃないでしょうか。

あ、言い忘れてましたが、実機動作確認はAndroidのみで行っています。
古いiPadはあるんだけど、もうOSアップデートの対象外なんですよね。
iPhone格安で手に入らないかな・・・SIM無しでいいから・・・

# 参考

Testing tidbit - Writing widget tests for navigation events
https://iiro.dev/2018/08/22/writing-widget-tests-for-navigation-events/