くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

Flutterのgo_routerに再入門する

久々に見直してたら、@TypedGoRouteを使って、
パスパラメタとかも型を意識して呼び出せるようになってた。

いろいろ忘れているので、再度入門したときの備忘録(*´ω`*)

インストール

$ fvm flutter pub add go_router
# buider系も追加
$ fvm flutter pub add -d go_router_builder build_runner build_verify 

pubspec.yamlはこんな感じ。

# pubspec.yaml
environment:
  sdk: ">=3.0.5 <4.0.0"
dependencies:
  flutter:
    sdk: flutter
  go_router: ^9.0.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  go_router_builder: ^2.2.0
  build_runner: ^2.4.6
  build_verify: ^3.1.0
  # hooks / riverpod系
  hooks_riverpod: ^2.3.6
  riverpod_annotation: ^2.1.1
  flutter_hooks:

ルートの準備

// routes.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// ...略

part "routes.g.dart";

// ********************************************************
// * Entrypoints
// * パスは定数にしてまとめておく
// ********************************************************
class AppRoutes {
  static const splashPage = "/splash";
  static const homePage = "/";
  static const userPage = "users/:uid";
}

// ********************************************************
// * RouteData
// * GoRouteDataをそれぞれ設定
// ********************************************************

// TOPレベルのパスには、@TypedGoRouteをつける
@TypedGoRoute<SplashPageRoute>(path: AppRoutes.splashPage)
class SplashPageRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) => const SplashPage();
}

// ネストしたルートがある場合は、`@TypedGoRoute.routes`に記載
@TypedGoRoute<HomePageRoute>(
  path: AppRoutes.homePage,
  routes: [
    TypedGoRoute<UserPageRoute>(path: AppRoutes.userPage),
  ],
)
class HomePageRoute extends GoRouteData {
  @override
  Widget build(BuildContext context, GoRouterState state) => const HomePage();
}

// TOPレベルでない場合は、`@TypedGoRoute`をつけない
@immutable
class UserPageRoute extends GoRouteData {
  const UserPageRoute({required this.uid});
  final int uid;

  @override
  Widget build(BuildContext context, GoRouterState state) => UserPage(uid: uid);
}

routerの準備

どこでも取得できるようにRiverpodでProvider化。

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

// さっきのルートの一覧
import 'routes.dart';

part "router.g.dart";

final GlobalKey<NavigatorState> _rootNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'root');

@riverpod
class AppRouter extends _$AppRouter {
  @override
  GoRouter build() {
    return GoRouter(
      navigatorKey: _rootNavigatorKey,
      debugLogDiagnostics: kDebugMode,
      initialLocation: AppRoutes.splashPage,
      routes: $appRoutes,
    );
  }
}

最後にMaterialApp.routerに設定する。

// app.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'router.dart';

class App extends HookConsumerWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(appRouterProvider);

    return MaterialApp.router(
      title: "My Sample App",
      // go_routerの設定
      restorationScopeId: 'router',
      routerDelegate: router.routerDelegate,
      routeInformationProvider: router.routeInformationProvider,
      routeInformationParser: router.routeInformationParser,
    );
  }
}

これで、各Routeのクラスが作成されて、
こんな感じで呼び出せるようになる。

TextButton(
    onPressed: () => const UserPageRoute(uid: 1).go(context),
    child: const Text('to User 1'),
)

Webでパスから#をなくす

いつのまにか設定方法が変わってた。。
flutter_web_pluginsurl_strategyを使う

$ fvm flutter pub add flutter_web_plugins --sdk=flutter
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_web_plugins:
    sdk: flutter

あとはrunApp()の前にusePathUrlStrategy()を呼べばOK

// main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'app.dart';

FutureOr<void> main() async {
  // go_routerの設定
  usePathUrlStrategy();
  runApp(const ProviderScope(child: App()));
}

ShellRouteでAppBarなどを固定する

このままだと、ページ遷移したときに、
AppBarはBottomNavigationBarもスライドされてしまう。。

ShellRouteを使うとそれらを固定できるよう。

使い方はこんな感じ。レイアウト的なこともこれでできそう。

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'routes.dart';
part "router.g.dart";

final GlobalKey<NavigatorState> _rootNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'root');
// ShellRoute用のKeyを追加
final GlobalKey<NavigatorState> _shellNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'shell');

@riverpod
class AppRouter extends _$AppRouter {
  @override
  GoRouter build() {
    // 現在のlocationを取得
    final current = GoRouterState.of(context).location;
    
    return GoRouter(
      navigatorKey: _rootNavigatorKey,
      debugLogDiagnostics: kDebugMode,
      initialLocation: AppRoutes.splashPage,
      routes: <RouteBase>[
        // ShellRouteを追加
        ShellRoute(
          navigatorKey: _shellNavigatorKey,
          builder: (BuildContext context, GoRouterState state, Widget child) {
            // ShellRouteで、AppLayout的なScaffoldを返す
            return Scaffold(
              appBar: AppBar(
                // ShellRoute内だと、戻るボタンが表示されないので処理を追加
                leading: _showLeading(context) ? _leadButton(context) : null,
                title: Text("My Sample App"),
              ),
              body: Center(child: child),
            );
          },
          // 自動生成した元のroutesを設定
          routes: $appRoutes,
        ),
      ],
    );
  }
  
  // 戻るボタン
  Widget _leadButton(BuildContext context) {
    return GestureDetector(
      onTap: () => context.pop(),
      child: const Icon(Icons.arrow_back),
    );
  }

  // 戻るボタンを表示するかの判定
  bool _showLeading(BuildContext context) {
    return ![
      AppRoutes.homePage,
      AppRoutes.splashPage,
    ].contains(GoRouterState.of(context).location);
  }
}

戻るボタンに関しては、これを参考にした感じ

ListenableでGoRouterにredirectを依頼する

GoRouterのredirectに処理を書いた場合でも、
パスの変更が発生しないと処理されない。

認証状態などが変化した場合に、redirectを実行してほしかったりする。

そういった場合は、Listenableを用意して、
GoRouter.refreshListenableを使うとよいらしい。

riverpodのgo_router+firebase_authのサンプルがわかりやすい。

ただドキュメントにも詳細がないので、かなり苦労した。。

Listenableはこんな感じ。riverpodを利用。

// router_listenable.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'providers.dart';
import 'routes.dart';

part 'router_listenable.g.dart';

@riverpod
class RouterListenable extends _$RouterListenable implements Listenable {
  VoidCallback? _routerListener;
  int _count = 0;

  // 他のProviderの値を監視する部分
  @override
  Future<void> build() async {
    // 10からカウントダウンするProviderの値を監視
    _count = ref.watch(countDownProvider);

    // ここで自分自身を監視。`_count`など`state`の値が変わったら、
    // GoRouterにredirectの処理を依頼する(=`_routerListener?.call()`)
    ref.listenSelf((prev, next) {
      // stateがloadingの場合は無視する
      if (state.isLoading) return;
      // oRouterにredirectの処理を依頼
      _routerListener?.call();
    });
  }

  // リダイレクトする条件をまとめておく部分
  String? redirect(BuildContext context, GoRouterState state) {
    // loading中やエラーがあるときは無視する
    if (this.state.isLoading || this.state.hasError) return null;

    // locationのパスやstateの値を条件を判定
    if (state.location == AppRoutes.splashPage && _count == 0) {
      // リダイレクト先のパスを返す
      return AppRoutes.homePage;
    }
    // リダイレクトしない場合はnullを返す
    return null;

  // GoRouterから渡されるcallbackの設定
  @override
  void addListener(VoidCallback listener) => _routerListener = listener;

  // GoRouterから渡されたcallbackの削除
  @override
  void removeListener(VoidCallback listener) => _routerListener = null;
}

次にGoRouter側の設定を変更する。

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'routes.dart';

// 追加
import 'router_listenable.dart';

part "router.g.dart";

final GlobalKey<NavigatorState> _rootNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'root');

@riverpod
class AppRouter extends _$AppRouter {
  @override
  GoRouter build() {
    // さっきのListenableを取得
    final routerListenable = ref.watch(routerListenableProvider.notifier);
    
    return GoRouter(
      navigatorKey: _rootNavigatorKey,
      debugLogDiagnostics: kDebugMode,
      initialLocation: AppRoutes.splashPage,
      routes: $appRoutes,
      
      // ListenableとredirectルールをGoRouterに設定
      refreshListenable: routerListenable,
      redirect: routerListenable.redirect,
    );
  }
}

ページ遷移のアニメーションを変更する

アプリ全体での指定

アプリ全体で指定する場合は、PageTransitionsThemeを使って、
themeで指定する感じ。

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        pageTransitionsTheme: const PageTransitionsTheme(
          // デフォルトのTransition
          // 指定していない場合は、ZoomPageTransitionsBuilderを利用
          builders: <TargetPlatform, PageTransitionsBuilder>{
            TargetPlatform.android: ZoomPageTransitionsBuilder(),
            TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
            TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
          },
        ),
      ),
      home: const HomePage(),
    );
  }
}

PageTransitionsThemeのデフォルト値などは、
ソースのこのあたり。

const PageTransitionsTheme({ Map<TargetPlatform, PageTransitionsBuilder> builders = _defaultBuilders }) : _builders = builders;

// デフォルトの値
static const Map<TargetPlatform, PageTransitionsBuilder> _defaultBuilders = <TargetPlatform, PageTransitionsBuilder>{
  TargetPlatform.android: ZoomPageTransitionsBuilder(),
  TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
  TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
};

// buildersに存在しないものは、ZoomPageTransitionsBuilderを利用
final PageTransitionsBuilder matchingBuilder =
  builders[platform] ?? const ZoomPageTransitionsBuilder();

特定ルートのみの指定

ある特定のルートに対しでのみ変更する場合は、
go_router側で設定する。

// 使いたいTransion
CustomTransitionPage _pageTransition(Widget page, GoRouterState state) {
  return CustomTransitionPage(
    key: state.pageKey,
    child: page,
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(
        opacity: CurveTween(curve: Curves.easeInOutCirc).animate(animation),
        child: child,
      );
    },
  );
}

// GoRouteでの指定
final route = GoRoute(
  path: '/',
  // builder()のかわりに、pageBuilderを利用
  pageBuilder: (context, state) => _pageTransition(const HomePage(), state),
);

// @TypedGoRouteでの指定
@TypedGoRoute<SplashPageRoute>(path = AppRoutes.splashPage)
class HomePageRoute extends GoRouteData {
  @override
  Page buildPage(BuildContext context, GoRouterState state) {
    return _pageTransition(const SplashPage(), state);
  }
}

go_router側のサンプルやFlutter公式のアニメーションに関するドキュメントはこのあたり。
いくつかライブラリも公開されている。


以上!! go_routerもbuilderができてだいぶ進化してる。。(*´ω`*)