riverpod + go_routerで遷移先を条件によって変える

13834 ワード

はじめに

Riverpodを使用したアプリで、GoRouterを導入した際に色々とつまづいたことがあったので、備忘録として残しておきます。
実現したいことは↓の内容になります。

  • Login前とLogin後で遷移先を変えたい
  • 特定の画面にDeeplinkで遷移しようとした際に、Loginされていなければ、Loginしてから遷移したい

Login前とLogin後で遷移先を変える

GoRouterでは、Redirectionを使用することで、特定の条件で遷移先を変えることができます。
例えばログインしていない場合は、強制的にログイン画面だけに遷移させるということができます。

シンプルな例

例えば、Initial RouteとLoginPageへのRouteがある場合を、redirectさせることだけを考えてシンプルに書くと、以下のようになります。

final loginInfo = LoginInfo();

GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomePage(),
    ),
    GoRoute(
      path: '/login',
      builder: (context, state) => LoginPage(),
    ),
  ],
  redirect: (state) {
    if (!loginInfo.isLoggedIn) {
      return state.subloc == '/login' ? null : '/login';
    }
    
    return null;
  },
  
)

これで、ログインしていない場合は、LoginPageへ強制的に遷移するようになります。
具体的には、redirect関数で特定のRouteへ遷移させたい場合、そのRouteのpathをreturnします。そのため、ログインしている場合は、/loginまたはnullを返して、ログインしている場合は、nullを返します。
nullを返した場合は、どこにもredirectすることなく、元々向かおうとしていた遷移先へ向かいます。
ログインしている際にも、sublocが/loginの場合にnullを返しています。この理由は、reirect関数はnullを返すまで処理を呼び続けるため、sublocが/loginの場合は、ログイン画面に向かって遷移してきた、もしくはログイン画面にredirectさせた場合なので、nullを返すようにして、redirectが再度呼び出されないようにします。

refreshListenable

上記の実装方法だと、LoginInfoが持つログイン状態が変更されても、GoRouterは検知することができません。そのため、ログイン後に別画面に遷移したい場合は、LoginしたことをLoginPageで検知して、遷移させる必要があります。
これをログイン状態が変わった際に、自動的にredirect関数を再度呼び出させて、別画面に遷移させたい場合、refreshListenableを使用することで、可能になります。

refreshListenable: loginInfo,

こうすることで、渡した値が変化したとき再度redirect関数が呼ばれるようになります。

redirect関数内も、loginInfoが更新された際に呼び出されることを考慮して、ログイン後はInitial Routeにredirectさせるコードに修正します。

redirect: (state) {
  if (!loginInfo.isLoggedIn) {
    return state.subloc == '/login' ? null : '/login';    }
  if (state.subloc == '/login') {
    return '/';
  }
  
  return null;
},
refreshListenable: loginInfo,

追加された部分をみると、sublocが/loginの場合、Initial Routeにredirectさせています。
LoginPageで何かしらのログイン処理を行い、LoginInfoの値が更新されると、refreshListenableにLoginInfoを渡しているため、redirect関数が呼ばれます。
LoginPageでログイン処理を行なっているので、/loginにいる状態でredirect関数が呼ばれるため、sublocが/loginとなります。

これで、LoginInfoが変更された際に、自動的に遷移させることができるようになりました。

riverpodを使用している場合

riverpodを使用している場合、LoginInfoに該当する部分を、Providerなどで使用していることもあると思いますが、そのままではProviderを参照できないので、GoRouter自体をProviderで囲んで参照できるようにします。

final routerProvider = Provider(
  (ref) => GoRouter(
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => HomePage(),
      ),
      GoRoute(
        path: '/login',
        builder: (context, state) => LoginPage(),
      ),
    ],
    redirect: (state) {
      final isLoggedIn = ref.read(loginInfoProvider).isLoggedIn;
      if (!isLoggedIn) {
        return state.subloc == '/login' ? null : '/login';
      }
    
      return null;
    },
    refreshListenable: ref.watch(loginInfoProvider),
  ),
);

Providerで囲んだことで、routeInformationParserとrouterDelegateを使用している箇所でも変更が必要になるので修正します。


Widget build(BuildContext context, WidgetRef ref) {
  return MaterialApp.router(
    routeInformationParser: ref.watch(routerPrivider).routeInformationParser,
    routerDelegate: ref.watch(routerPrivider).routerDelegate,
  );
}

これで、GoRouter内でもriverpodを使用したProviderなどの値を使用することができるようになります。

変更を見たいProviderがStateNotifierProviderの場合

refreshListenableには、Listenableしかセットできません。
そのため、Listenableを継承しているChangeNotifierProviderはそのまましようできるのですが、StateNotifierProviderなどを使用している場合は、変更する必要があります。

変更方法の一例ではありますが、ref.listenを使用してrouterProviderを更新させる場合は、以下のようになります。

final loginProvider = StateNotifierProvider(...);

ref.listen(
  loginInfoProvider, (_, __) { 
    ref.refresh(routerProvider);
  },
);

このようにすることで、StateNotifierProviderの変更が通知されたタイミングで、routerProviderの方も更新をさせることができます。

Redirect前のRouteに遷移させる

ログイン画面などにRedirectされる条件の場合、上記の実装では問答無用でInitial Routeに遷移されてしまいます。
これを、遷移前のRouteをクエリに渡して、ログイン後にredirect関数が呼ばれた際に使用することで、もともとの目的の画面に遷移できるようにします。

redirect: (state) {
  final isLoggedIn = ref.read(loginInfoProvider).isLoggedIn;
  final isLoggingIn = state.subloc == '/login';
  
  final fromParam = isLoggingIn ? '' : '?from=${state.subloc}';
  
  if (!isLoggedIn) {
    return isLoggingIn ? null : '/login';
  }
  
  if (isLoggingIn) {
    return state.queryParams['from'] ?? '/';
  }
    
  return null;
},

追加された部分を見てみると、まずstate.sublocが/loginかのisLoggingInを追加しています。
これは、LoginPageでログイン状態を更新させた場合、refreshListenableで変更を検知して、redirect関数が呼ばれますが、その際のsublocは/loginのままなので、ログイン状態が更新されたあとに呼ばれているのかを判定するために使用します。
そして、isLoggingInがtrueの場合に、fromパラメータが渡されている可能性があるので、state.queryParamsを使用してfromクエリを取得し、あればLoginPageにredirectされる前のpath、なければInitial RouteのPathを返しています。

Refreshさせる動作の修正

上記のようにfromクエリを使用して遷移する場合、リダイレクト後にrefreshされてしまうと、Initial Routeに遷移してしまいます。
GoRouterをProviderで囲っている場合、MaterialApp.router()で使用するために、ref.watch(routerProvider)というようにしていると思いますが、これをしながらrefreshListenableを使用していると、更新が2回されてしまいInitial Routerに遷移してしまいます。
そのため、呼び出している側で、refreshさせる処理を追加します。

final router = ref.watch(routerProvider);
ref.listen(loginInfoProvider, (_, __) {
  router.refresh();
});

return MaterialApp.router(
  title: 'Flutter Demo',
  theme: ThemeData(
    primarySwatch: Colors.blue,
  ),
  routeInformationParser: router.routeInformationParser,
  routerDelegate: router.routerDelegate,
);

まず、ref.watchを使用して、GoRouterを取得します。
そして、ref.listenを使用して、loginInfoProviderが変更されたタイミングで、routerProviderの方のrefreshを呼び出します。
routerInformationParserとrouterDelegateの方で、ref.watchを使用してしまうと、その分refreshされてInitialRouteになってしまうので、ref.watchは使用しないようにしています。

終わり

これで、Login前とLogin後で遷移先を変えるということを実現することができました。

ref.watchとrefreshListenable組み合わせた場合の動作が、結構分かりづらくてハマっていたので、この辺の動作がどうなっているのかを後でちゃんと調べようと思います🙏