🛫

【Flutter】Go Router から Go Router Builder へ

2023/11/15に公開

初めに

以下の記事では Navigator と Go Router を比較して簡単な画面遷移がどのように書けるかを学んできました。本記事では Go Router をタイプセーフで書くことができる Go Router Builder に関して学んでいきたいと思います。

https://zenn.dev/koichi_51/articles/50c553218ca938

記事の対象者

  • Flutter 学習者
  • Go Router を使いたい方
  • Go Router Builder を使いたい方

実装

導入

以下の三つのパッケージの最新バージョンをpubspec.yamlに記述

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  go_router: ^12.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.6
  go_router_builder: ^2.3.4

または

以下をターミナルで実行

flutter pub add go_router
flutter pub add -d build_runner  go_router_builder

現在のコード

こちらの記事では Go Router を使用していたため、以下のように router.dartGoRouter に全てのルートを指定しています。

router.dart
import 'package:go_router/go_router.dart';
import 'package:go_router_builder_sample/screens/about_screen.dart';
import 'package:go_router_builder_sample/screens/detail_screen.dart';
import 'package:go_router_builder_sample/screens/home_screen.dart';
import 'package:go_router_builder_sample/screens/setting_screen.dart';

final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/',
  routes: [
    GoRoute(
        name: 'home',
        path: '/',
        routes: [
          GoRoute(
            name: 'detail',
            path: 'detail/:user_name/:user_id',
            builder: (context, state) {
              final userName = state.pathParameters['user_name'];
              final userId = state.pathParameters['user_id'];
              return DetailScreen(
                userName: userName!,
                userId: int.parse(userId!),
              );
            },
          ),
        ],
        builder: (context, state) => const HomeScreen()),
    GoRoute(
        name: 'about',
        path: '/about',
        builder: (context, state) => const AboutScreen()),
    GoRoute(
        name: 'setting',
        path: '/setting',
        builder: (context, state) => const SettingScreen()),
  ],
);
全体のコード
main.dart
import 'package:flutter/material.dart';
import 'package:go_router_builder_sample/router.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Go Router Sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      routerDelegate: router.routerDelegate,
      routeInformationParser: router.routeInformationParser,
      routeInformationProvider: router.routeInformationProvider,
    );
  }
}
router.dart
import 'package:go_router/go_router.dart';
import 'package:go_router_builder_sample/screens/about_screen.dart';
import 'package:go_router_builder_sample/screens/detail_screen.dart';
import 'package:go_router_builder_sample/screens/home_screen.dart';
import 'package:go_router_builder_sample/screens/setting_screen.dart';

final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/',
  routes: [
    GoRoute(
        name: 'home',
        path: '/',
        routes: [
          GoRoute(
            name: 'detail',
            path: 'detail/:user_name/:user_id',
            builder: (context, state) {
              final userName = state.pathParameters['user_name'];
              final userId = state.pathParameters['user_id'];
              return DetailScreen(
                userName: userName!,
                userId: int.parse(userId!),
              );
            },
          ),
        ],
        builder: (context, state) => const HomeScreen()),
    GoRoute(
        name: 'about',
        path: '/about',
        builder: (context, state) => const AboutScreen()),
    GoRoute(
        name: 'setting',
        path: '/setting',
        builder: (context, state) => const SettingScreen()),
  ],
);
home_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  static String get routeName => 'home';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Home")),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            context.go('/detail/Koichi5/101');
          },
          child: const Text(
            "Go To Detail Screen",
          ),
        ),
      ),
    );
  }
}
detail_screen.dart
import 'package:flutter/material.dart';

class DetailScreen extends StatelessWidget {
  const DetailScreen({super.key, required this.userName, required this.userId});

  final String userName;
  final int userId;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
      body: Center(
        child: Text(
          "Hello $userName ! \n Your ID is $userId.",
        ),
      ),
    );
  }
}
about_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class AboutScreen extends StatelessWidget {
  const AboutScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('About'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            context.pop();
          },
          child: const Text(
            'Go To Setting Screen',
          ),
        ),
      ),
    );
  }
}
setting_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class SettingScreen extends StatelessWidget {
  const SettingScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Setting')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            context.go('/');
          },
          child: const Text(
            'Go To Home Screen',
          ),
        ),
      ),
    );
  }
}

ルートの指定

まずは HomeScreenDetailScreen のルートの実装を行いましょう。
※単純化のため DetailScreen を以下のように引数を持たない単純なページに変更しています。

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

class DetailScreen extends StatelessWidget {
  const DetailScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
    );
  }
}

router.dart を以下のように変更します。

router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_builder_sample/screens/detail_screen.dart';
import 'package:go_router_builder_sample/screens/home_screen.dart';

part 'router.g.dart';

final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/',
  routes: $appRoutes,
);

<HomeRoute>(
    path: '/',
    routes: [
      TypedGoRoute<DetailRoute>(path: 'detail')
    ]
)
class HomeRoute extends GoRouteData {
  const HomeRoute();

  
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

class DetailRoute extends GoRouteData {
  const DetailRoute();

  
  Widget build(BuildContext context, GoRouterState state) =>
      const DetailScreen();
}

TypedGoRoute アノテーションを使用することで、後述の処理で指定したルートが自動生成されます。あるページの子要素、入れ子になるページは routes に指定している点は Go Router と同じであると言えます。
routes: $appRoutes では後述の自動生成されたコードの中でTypedGoRoute アノテーションを用いて生成されたルートのリストが appRoutes という変数に格納されており、それが routes に指定されています。

上のコードと同じ Go Router のコード
router.dart
import 'package:go_router/go_router.dart';
import 'package:go_router_builder_sample/screens/detail_screen.dart';
import 'package:go_router_builder_sample/screens/home_screen.dart';

final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/',
  routes: [
    GoRoute(
      name: 'home',
      path: '/',
      routes: [
        GoRoute(
          path: 'detail',
          builder: (context, state) {
            return const DetailScreen();
          },
        ),
      ],
      builder: (context, state) => const HomeScreen(),
    ),
  ],
);



そして以下のコードを実行することで、ルートのコードが自動生成されます。

flutter pub run build_runner build --delete-conflicting-outputs

自動生成されたコードを見てみると、以下のように go push pushReplacement のほかにlocation として今のパスを取得できるような変数も生成されていることがわかります。

router.g.dart
extension $HomeRouteExtension on HomeRoute {
  static HomeRoute _fromState(GoRouterState state) => const HomeRoute();

  String get location => GoRouteData.$location(
        '/',
      );

  void go(BuildContext context) => context.go(location);

  Future<T?> push<T>(BuildContext context) => context.push<T>(location);

  void pushReplacement(BuildContext context) =>
      context.pushReplacement(location);

  void replace(BuildContext context) => context.replace(location);
}

画面遷移を行いたいときは以下のように遷移先のルートを指定して go で遷移することができます。

home_screen.dart
const DetailRoute.go(context);

エラーがなくなった段階で実際に動かしてみると、遷移先として戻るボタンがある DetailScreenに遷移することがわかります。

値渡し①

まずは DetailScreenuserName というString型の変数を受け取るように変更します。

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

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    super.key, 
+   required this.userName,
  });

+ final String userName;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
+     body: Center(
+       child: Text(
+         "Hello $userName !",
+       ),
+     ),
    );
  }
}

Go Router

router.dart
GoRoute(
  name: 'detail',
  path: 'detail',
  builder: (context, state) => DetailScreen(
    userName: state.extra as String,
  ),
),
home_screen.dart
context.go('/detail', extra: 'Koichi5');

Go Router では、router.dartDetailScreen に渡す引数を state.extra で指定し、実際に遷移する処理では extraプロパティに渡したい値を指定していました。

Go Router Builder

router.dart
class DetailRoute extends GoRouteData {
  const DetailRoute(
+ {required this.userName}
  );

+ final String userName;

  
  Widget build(BuildContext context, GoRouterState state) =>
      DetailScreen(
+       userName: userName
      );
}

DetailRoute の方で userName という変数名で String 型の入力を受け付け、それを DetailScreen に代入するように変更します。

ここで重要なのは、先ほど定義した TypedGoRoute<DetailRoute>(path: 'detail') は変更する必要ないという点です。

再度以下のビルドランナーを実行してルートの内容を更新します。

flutter pub run build_runner build --delete-conflicting-outputs

遷移したいページで以下のように遷移先のページの引数に渡したい値を代入して遷移すれば画像のように遷移先のページで変数を使うことができます。

home_screen.dart
const DetailRoute(userName: 'Koichi5').go(context);

throw_params

なお、遷移する際のページのルートは以下のようになっています。

going to /detail?user-name=Koichi5

userNameとしてキャメルケースだった変数名はuser-nameというケバブケースに変更されていました。

値渡し②

Go Router

router.dart
GoRoute(
  name: 'detail',
  path: 'detail/:user_name,
  builder: (context, state) {
    final userName = state.pathParameters['user_name'];
    return DetailScreen(
      userName: userName!,
    );
  },
),
detail_screen.dart
import 'package:flutter/material.dart';

class DetailScreen extends StatelessWidget {
  const DetailScreen({super.key, required this.userName});

  final String userName;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
      body: Center(
        child: Text(
          "Hello $userName !",
        ),
      ),
    );
  }
}
home_screen.dart
context.go('/detail/Koichi5');

Go Router では以上のように、GoRouteのパスに:変数名を入れることで値を渡すこともできました。
パスとして渡された変数を取り出すためには state.pathParameters['user_name'] のように state.pathParameters で一致する変数名を代入すれば取り出すことができました。

Go Router Builder

router.dart
TypedGoRoute<DetailRoute>(path: 'detail/:userName')

Go Router Builder では、先ほどの「値渡し①」の変更に加え、router.dartの値を受け取りたい側のページのパスに、DetailRouteuserName のように同じ変数を :変数名として指定することで、以下のように受け取る変数をパスに含める形で渡すことができます。

going to /detail/Koichi5
この時の関連コード
router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_builder_sample/screens/detail_screen.dart';
import 'package:go_router_builder_sample/screens/home_screen.dart';

part 'router_builder.g.dart';

final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/',
  routes: $appRoutes,
);

<HomeRoute>(
    path: '/', routes: [TypedGoRoute<DetailRoute>(path: 'detail/:userName')])
class HomeRoute extends GoRouteData {
  const HomeRoute();

  
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

class DetailRoute extends GoRouteData {
  const DetailRoute(this.userName);

  final String userName;

  
  Widget build(BuildContext context, GoRouterState state) =>
      DetailScreen(userName: userName);
}
detail_screen.dart
import 'package:flutter/material.dart';

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    super.key,
    required this.userName,
  });

  final String userName;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
      body: Center(
        child: Text(
          "Hello $userName !",
        ),
      ),
    );
  }
}
home_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router_builder_sample/router_builder.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  static String get routeName => 'home';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Home")),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            const DetailRoute('Koichi5').go(context);
          },
          child: const Text(
            "Go To Detail Screen",
          ),
        ),
      ),
    );
  }
}

このコードでは単一のパラメーターしか受け取っていないため、問題ありませんが、複数のパラメーターをパスに含める場合はパラメーターを受け取る DetailRoute に関しては required をつけて受け取る変数名を明示した方が良いかと思います。

値渡し③

extra を使った値渡しも実装することができます。

router.dart
class DetailRoute extends GoRouteData {
  const DetailRoute({required this.userName, this.$extra});

  final String userName;
  final int? $extra;

  
  Widget build(BuildContext context, GoRouterState state) =>
      DetailScreen(userName: userName, extra: $extra);
}
detail_screen.dart
import 'package:flutter/material.dart';

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    super.key,
    required this.userName,
    this.extra
  });

  final String userName;
  final int? extra;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
      body: Center(
        child: Text(
          "Hello $userName ! \n Your ID is $extra !"
        ),
      ),
    );
  }
}
home_screen.dart
const DetailRoute(userName: 'Koichi5', $extra: 101).go(context);

以上のように router.dart$extra という名前のパラメータを引数と指定することで、以下の画像のようにパラメータを渡すことができます。
throw_variables_with_extra

ただ、この時の注意点としてルートの表示が以下のようになり、パラメータが渡されていることがわかりにくいという点があるので、注意しましょう。

going to /detail/Koichi5

複数値渡し

パスパラメータ、クエリパラメータ、extra の三つを組み合わせた値渡しを実装してみます。
以下のように DetailRoute でそれぞれパスパラメータの userName、クエリパラメータの userAge、extra の $extra を受け取るようにします。

router.dart
<HomeRoute>(
    path: '/', routes: [TypedGoRoute<DetailRoute>(path: 'detail/:userName')])
class HomeRoute extends GoRouteData {
  const HomeRoute();

  
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

class DetailRoute extends GoRouteData {
  const DetailRoute({required this.userName, this.userAge, this.$extra});

  final String userName;
  final int? userAge;
  final int? $extra;

  
  Widget build(BuildContext context, GoRouterState state) =>
      DetailScreen(userName: userName, userAge: userAge, extra: $extra);
}

DetailScreen では先ほど指定した三つの変数を受け取るようにします。

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

class DetailScreen extends StatelessWidget {
  const DetailScreen(
      {super.key, required this.userName, this.userAge, this.extra});

  final String userName;
  final int? userAge;
  final int? extra;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail"),
      ),
      body: Center(
        child: Text("Hello $userName ! \n Your Age is $userAge! \n Your ID is $extra !"),
      ),
    );
  }
}

遷移元の HomeScreen では以下のように三つの引数を渡して遷移します。

home_screen.dart
const DetailRoute(userName: 'Koichi5', userAge: 20, $extra: 101).go(context);

ビルドランナーを実行して、ビルドすると以下の画像のように三つのパラメータが DetailScreen に渡されていることがわかります。
mixed_params

なお、遷移する際のパスは以下のようになります。

going to /detail/Koichi5?user-age=20

パスパラメータは正確にパスに組み込まれています。
クエリパラメータはパスの後ろに ? がついた形でケバブケースで代入されています。
extra はパスには含まれませんが、値は渡されています。

エラー

エラーが発生した時に遷移するページを指定することもできます。

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

class ErrorScreen extends StatelessWidget {
  const ErrorScreen({super.key, required this.error});

  final Exception error;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('エラー'),
      ),
      body: Center(
          child: Column(
        children: [
          Text(
            error.toString(),
          ),
          const Text('申し訳ありませんが、もう一度お試しください')
        ],
      )),
    );
  }
}
router.dart
final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/',
  routes: $appRoutes,
+ errorBuilder: (context, state) => ErrorRoute(error: state.error!).build(context, state),
);

// 以下の ErrorRoute を追加
class ErrorRoute extends GoRouteData {
  ErrorRoute({required this.error});

  final Exception error;

  
  Widget build(BuildContext context, GoRouterState state) =>
      ErrorScreen(error: error);
}

以上のように ErrorScreen とそれに対応する ErrorRoute を作成し、GoRoutererrorBuilder に渡すとエラーが発生した時に指定した ErrorScreen に遷移させることができます。

今でもルーティングに関してエラーが発生した場合は、その原因と元のページに戻るための導線が用意されているのですが、UXとしては最低限のものになるかと思うので、ErrorRoute を設けてUXの損失を最小限にすることは良いことと言えるのではないでしょうか?

使ってみて感じたこと

Go Router と Go Router Builder 両方を使ってみてのメリットとデメリットを感じたまままとめます。

メリット

  • ルーティングを一つのファイルにまとめて管理できる
  • ルートを指定してしまえば遷移の記述が楽
  • redirect が標準で実装されているため、アップデート後の画面表示や未ログイン時の実装が簡単にできそう(これから追加実装するかもしれません)

デメリット

  • スクリーン数の少ないアプリでは逆に手間が増えるかもしれない
  • NavigatorではUIのコードを読んでいて、遷移先のページを確認したい時には command を押したままコードをクリックすれば確認できたが、Go Router、Go Router Builder ではルーティングが分離されているため確認できない(細かいところですが...)
  • 独自でカスタムした型を簡単に次のページに渡すことができない

まとめ

最後まで読んでいただいてありがとうございました。
特に Go Router Builder に関してはまだまだ変化が激しいパッケージかと思うので、これからも定期的にアップデートできたらと思います。
触れ始めて日が浅いのでもっと良い実装方法等あればご指摘いただければ幸いです。

参考

https://pub.dev/packages/go_router_builder

https://zenn.dev/flutteruniv_dev/articles/20220801-135028-flutter-go-router-builder

Discussion