4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Flutter WebでNavigator 2.0を使って各ページにURLを設定する

Last updated at Posted at 2022-01-28

はじめに

Flutter Webで開発を進めていて、『画面毎のURL設定』というタスクに行き着いた時、ん?これはどうすればいいんだ?となったわけです。
URLを設定するというだけであれば、方法はいくつかあったのですが、

  • タブ(BottomNavigationBar, NavigationRail)を含む画面にも対応する
  • ブラウザの進む/戻るボタンに対応する

などとなったときに、調べ尽くして行き着いた結論は「Navigator 2.0とやらを使うしかねえなぁ」でした。

Navigator 2.0を使って簡単な画面を作る

Navigator 2.0とは従来のpush, popによる命令的な画面stack管理から脱却し、アプリの状態に従って宣言的な画面管理を行うために作られた機構です。

まずはNavigator 2.0を使ってログイン画面とホーム画面だけの簡単な画面の遷移を実現してみます。

画面状態管理

最初に、アプリの画面状態を管理するためのclassを用意します。

route_state.dart
class RouteState extends ChangeNotifier {
  bool _isLogin = false;

  bool get isLogin => _isLogin;

  set isLogin(bool flag) {
    _isLogin = true;
    notifyListeners();
  }
}

また、画面となるAuthHomeのclassも用意します(コードは省略)

RoutePath

次に画面の状態と、その画面が持つURLを定義するclassを用意します。

sample_route_path.dart
class SampleRoutePath {
  final Uri uri;

  SampleRoutePath.auth() : uri = Uri(path: '/');
  SampleRoutePath.home() : uri = Uri(path: '/home');

  bool get isAuthSection => (uri == SampleRoutePath.auth().uri);
  bool get isHomeSection => (uri == SampleRoutePath.home().uri);
}

RouteInformationParser

次にRouteInformationParserをextendsしたclassを用意します。
このclassはparseRouteInformation()restoreRouteInformation()のmethodを持っています。

parseRouteInformation()はアプリの起動、更新、Webブラウザの進む/戻るボタン、 URL直接入力といったOSからの通知を元に呼び出されます。
引数のRouteInformationはURLを持っているため、現在のURLを判定して適切なSampleRoutePathを返す処理を実装します。

restoreRouteInformation()はアプリ側から呼び出され、SampleRoutePathの変更があった際にURLの変更を含んだRouteInformationを構築し、OS側へ通知します。これによってブラウザのロケーションバーのURLを動的に変更することができます。

sample_route_information_parser.dart
class SampleRouteInformationParser extends RouteInformationParser<SampleRoutePath> {
  @override
  Future<SampleRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
    // uriをチェックし、該当するSampleRoutePathを返す
    final uri = Uri.parse(routeInformation.location ?? '');
    if (uri.pathSegments.length == 1) {
      if (uri.pathSegments[0] == SampleRoutePath.home().uri.pathSegments[0]) {
        return SampleRoutePath.home();
      }
    }
    return SampleRoutePath.auth();
  }

  @override
  RouteInformation? restoreRouteInformation(SampleRoutePath configuration) {
    // configurationをもとに適切なURLを設定し、RouteInformationを構築する
    if (configuration.isAuthSection) {
      return RouteInformation(location: SampleRoutePath.auth().uri.path);
    }
    if (configuration.isHomeSection) {
      return RouteInformation(location: SampleRoutePath.home().uri.path);
    }
    return null;
  }
}

RouterDelegate

次に、RouterDelegateをextendsしたclassを用意します。
RouterDelegateNavigatorをbuildし、画面構成を管理します。
画面状態の変更に合わせて、適切な画面を構築する役割を担っています。

sample_router_delegate.dart
class SampleRouterDelegate extends RouterDelegate<SampleRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<SampleRoutePath> {
  @override
  final GlobalKey<NavigatorState> navigatorKey;
  final RouteState _routeState;

  SampleRouterDelegate(this._routeState) : navigatorKey = GlobalKey<NavigatorState>() {
    // _routeStateにlistenerを設定することで、状態の変更を検知できるようにしている
    _routeState.addListener(notifyListeners);
  }

  // Routerが自身をリビルドするときにcurrentConfigurationを呼び出し
  // restoreRouteInformation()の引数として渡される
  @override
  SampleRoutePath get currentConfiguration {
    if (_routeState.isLogin) return SampleRoutePath.home();
    return SampleRoutePath.auth();
  }

  @override
  Widget build(BuildContext context) {
    // Navigatorで画面構成を管理する
    return Navigator(
      key: navigatorKey,
      pages: [
        const MaterialPage(
          child: Auth(),
        ),
        if (_routeState.isLogin)
          const MaterialPage(
            child: Home(),
          ),
      ],
      onPopPage: (route, result) {
        return false;
      },
    );
  }

  // OS側からアプリ状態の変更があった際、parseRouteInformation()が行われた後、
  // その結果を引数としてsetNewRoutePathが呼ばれる
  @override
  Future<void> setNewRoutePath(SampleRoutePath configuration) async {
    _routeState.isLogin = configuration.isHomeSection;
  }
}

例えばこの例であれば、最初にAuth->Homeの順に画面がstackされている構成となります。
ログインが完了していればHomeが、そうでなければAuthが表示されるということです。

MaterialApp.router

あとはmainを構築します
MaterialApp.routerをbuildし、routeInformationParser, routerDelegeteをそれぞれ設定することで、それ以下の画面構成がRouterで管理されます。
状態管理には今回はproviderを利用し、RouteStateおよびSampleRouteDelegateを管理します。(これでRouteStateをどの画面からも変更でき、なおかつその変更が画面やURLに反映されるようになります)

main.dart
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => RouteState()),
        ChangeNotifierProvider(create: (context) => SampleRouterDelegate(context.read<RouteState>())),
      ],
      child: _MyAppWidget(),
    );
  }
}

class _MyAppWidget extends StatelessWidget {
  final SampleRouteInformationParser _sampleRouteInformationParser = SampleRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: _sampleRouteInformationParser,
      routerDelegate: context.read<SampleRouterDelegate>(),
    );
  }
}

これで2つの画面の画面遷移をNavigator 2.0で完全にフォローできました。

ezgif-7-6aeecbd6bd.gif

Navigator 2.0をタブ切り替え画面に適用させる

では、上記のアプリケーションに追加実装する形で、Home画面をタブ切り替え可能な画面にした場合に、Navigator 2.0をどのように適用すればいいのでしょうか。

おそらく最適解と考えられるのは、Home内にRouterをもう一つ用意(NestedRouter)し、タブおよびその配下の画面構成をそちらで管理するという方法です。

具体的なコードをベースに見ていきます。以下の仕様を入れ込むことを考えます。

  • Home画面にタブを持たせてBooks, Info, Settingsの画面を切り替えできるようにする
  • Books画面からはボタン押下でBookDetail画面へ遷移

画面状態管理の更新

まず画面の変更に伴って、 RouteStateを見直します。

route_state.dart
class RouteState extends ChangeNotifier {
  bool _isLogin = false;
  int _homeIndex = 0;
  bool _isBookDetail = false;

  bool get isLogin => _isLogin;
  int get homeIndex => _homeIndex;
  bool get isBookDetail => _isBookDetail;

  void handleLogin() {
    _isLogin = true;
    _homeIndex = 0;
    _isBookDetail = false;
    notifyListeners();
  }

  void handleLogout() {
    _isLogin = false;
    _homeIndex = 0;
    _isBookDetail = false;
    notifyListeners();
  }

  void handleHomeIndex(int index) {
    _isLogin = true;
    _homeIndex = index;
    _isBookDetail = false;
    notifyListeners();
  }

  void handleBookDetail() {
    _isLogin = true;
    _homeIndex = 0;
    _isBookDetail = true;
    notifyListeners();
  }
}

InnerRouterDelegate

内部のRouterとしてSampleInnerRouterDelegateを用意します。
タブ以降の各画面をNavigatorに持たせます。

sample_inner_router_delegate.dart
class SampleInnerRouterDelegate extends RouterDelegate<SampleRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<SampleRoutePath> {
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  SampleInnerRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    RouteState routeState = context.read<RouteState>();
    // Navigatorをnestさせるとタブ部分に影ができてしまうので、ClipRectで回避している
    return ClipRect(
      child: Navigator(
        key: navigatorKey,
        pages: [
          if (routeState.homeIndex == 0) ...[
            const MaterialPage(child: Books()),
            if (routeState.isBookDetail) const MaterialPage(child: BookDetail()),
          ],
          if (routeState.homeIndex == 1) const MaterialPage(child: Info()),
          if (routeState.homeIndex == 2) const MaterialPage(child: Settings()),
        ],
        onPopPage: (route, result) {
          return false;
        },
      ),
    );
  }

  @override
  Future<void> setNewRoutePath(SampleRoutePath configuration) async {
    // InnerRouteDelegateでは、setNewRoutePathは空実装で問題ない
  }
}

(Books, Infoなど各画面のコードは省略します)

SampleInnerRouterDelegateではOSとのRouteInformationのやりとりを行わないため、 currencyConfigurationsetNewRoutePath()の実装は不要です。

もちろんその代わり、SampleRoutePath, SampleRouteInformationPerser, SampleRouterDelegateのほうでは新しく追加する各画面に対する設定を追加する必要があります

RoutePathの更新

sample_route_path.dart
class SampleRoutePath {
  final Uri uri;

  SampleRoutePath.auth() : uri = Uri(path: '/');
  SampleRoutePath.books() : uri = Uri(path: '/books');
  SampleRoutePath.info() : uri = Uri(path: '/info'); 
  SampleRoutePath.settings() : uri = Uri(path: '/settings');
  SampleRoutePath.bookDetail() : uri = Uri(path: '/books/detail');

  bool get isAuthSection => (uri == SampleRoutePath.auth().uri);
  bool get isBooksSection => (uri == SampleRoutePath.books().uri);
  bool get isInfoSection => (uri == SampleRoutePath.info().uri);
  bool get isSettingsSection => (uri == SampleRoutePath.settings().uri);
  bool get isBookDetailSection => (uri == SampleRoutePath.bookDetail().uri);
}

RouteInformationParserの更新

sample_route_information_parser.dart
class SampleRouteInformationParser extends RouteInformationParser<SampleRoutePath> {
  @override
  Future<SampleRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
    // uriをチェックし、該当するSampleRoutePathを返す
    final uri = Uri.parse(routeInformation.location ?? '');
    if (uri.pathSegments.length == 1) {
      if (uri.pathSegments[0] == SampleRoutePath.books().uri.pathSegments[0]) {
        return SampleRoutePath.books();
      }
      if (uri.pathSegments[0] == SampleRoutePath.info().uri.pathSegments[0]) {
        return SampleRoutePath.info();
      }
      if (uri.pathSegments[0] == SampleRoutePath.settings().uri.pathSegments[0]) {
        return SampleRoutePath.settings();
      }
    }
    if (uri.pathSegments.length == 2) {
      if (uri.pathSegments[0] == SampleRoutePath.bookDetail().uri.pathSegments[0] &&
          uri.pathSegments[0] == SampleRoutePath.bookDetail().uri.pathSegments[0]) {
        return SampleRoutePath.bookDetail();
      }
    }
    return SampleRoutePath.auth();
  }

  @override
  RouteInformation? restoreRouteInformation(SampleRoutePath configuration) {
    // configurationをもとに適切なURLを設定し、RouteInformationを構築する
    if (configuration.isAuthSection) {
      return RouteInformation(location: SampleRoutePath.auth().uri.path);
    }
    if (configuration.isBooksSection) {
      return RouteInformation(location: SampleRoutePath.books().uri.path);
    }
    if (configuration.isInfoSection) {
      return RouteInformation(location: SampleRoutePath.info().uri.path);
    }
    if (configuration.isSettingsSection) {
      return RouteInformation(location: SampleRoutePath.settings().uri.path);
    }
    if (configuration.isBookDetailSection) {
      return RouteInformation(location: SampleRoutePath.bookDetail().uri.path);
    }
    return null;
  }
}

RouterDelegateの更新

sample_router_delegate.dart
class SampleRouterDelegate extends RouterDelegate<SampleRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<SampleRoutePath> {

...

  @override
  SampleRoutePath get currentConfiguration {
    if (!_routeState.isLogin) return SampleRoutePath.auth();
    if (_routeState.isBookDetail) return SampleRoutePath.bookDetail();
    switch (_routeState.homeIndex) {
      case 0:
        return SampleRoutePath.books();
      case 1:
        return SampleRoutePath.info();
      case 2:
        return SampleRoutePath.settings();
      default:
        return SampleRoutePath.books();
    }
  }

...

  @override
  Future<void> setNewRoutePath(SampleRoutePath configuration) async {
    if (configuration.isBooksSection) _routeState.handleHomeIndex(0);
    if (configuration.isInfoSection) _routeState.handleHomeIndex(1);
    if (configuration.isSettingsSection) _routeState.handleHomeIndex(2);
    if (configuration.isBookDetailSection) _routeState.handleBookDetail();
    if (configuration.isAuthSection) _routeState.handleLogout();
  }
}

Homeの更新

あとはHomeをタブを持つ形式にし、SampleInnerRouterDelegateを引数に持つRouterを持たせる。

home.dart
// SampleInnerRouterDelegateをinitStateでinitializeするために
// HomeをStatefulWidgetに変更した
class Home extends StatefulWidget {
  const Home({Key? key}) : super(key: key);

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  late SampleInnerRouterDelegate _sampleInnerRouterDelegate;

  @override
  void initState() {
    super.initState();
    _sampleInnerRouterDelegate = SampleInnerRouterDelegate();
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        // 今回はTabとしてNavigationRailを採用
        // BottomNavigationBarなどでも同様のことが可能
        NavigationRail(
          selectedIndex: context.watch<RouteState>().homeIndex,
          onDestinationSelected: (int index) => context.read<RouteState>().handleHomeIndex(index),
          destinations: const [
            NavigationRailDestination(icon: Icon(Icons.analytics_rounded), label: Text('Books')),
            NavigationRailDestination(icon: Icon(Icons.accessibility_rounded), label: Text('Info')),
            NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')),
          ],
        ),
        const VerticalDivider(thickness: 1, width: 1),
        Expanded(
          // Routerを持たせ、引数にSampleInnerRouterDelegateを指定する
          child: Router(routerDelegate: _sampleInnerRouterDelegate),
        )
      ],
    );
  }
}

これでNavigator 2.0を全ての画面に対して適用することができました。

ezgif-7-95855df251.gif

参考資料

Navigator 2.0については、以下の記事が大変参考になります。
https://zenn.dev/ntaoo/articles/6641e846765da1
https://zenn.dev/ntaoo/articles/e647ceaacb7214

また、タブ切り替えについては以下のStackOverflowの記事をベースに再構築しています。
https://stackoverflow.com/questions/64457071/flutter-web-bottom-navigation-bar-with-url-updating-and-hyperlink-support

4
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?