はじめに
Flutter Webで開発を進めていて、『画面毎のURL設定』というタスクに行き着いた時、ん?これはどうすればいいんだ?となったわけです。
URLを設定するというだけであれば、方法はいくつかあったのですが、
- タブ(BottomNavigationBar, NavigationRail)を含む画面にも対応する
- ブラウザの進む/戻るボタンに対応する
などとなったときに、調べ尽くして行き着いた結論は「Navigator 2.0とやらを使うしかねえなぁ」でした。
Navigator 2.0を使って簡単な画面を作る
Navigator 2.0とは従来のpush, popによる命令的な画面stack管理から脱却し、アプリの状態に従って宣言的な画面管理を行うために作られた機構です。
まずはNavigator 2.0を使ってログイン画面とホーム画面だけの簡単な画面の遷移を実現してみます。
画面状態管理
最初に、アプリの画面状態を管理するためのclassを用意します。
class RouteState extends ChangeNotifier {
bool _isLogin = false;
bool get isLogin => _isLogin;
set isLogin(bool flag) {
_isLogin = true;
notifyListeners();
}
}
また、画面となるAuth
とHome
のclassも用意します(コードは省略)
RoutePath
次に画面の状態と、その画面が持つURLを定義するclassを用意します。
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を動的に変更することができます。
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を用意します。
RouterDelegate
はNavigator
をbuildし、画面構成を管理します。
画面状態の変更に合わせて、適切な画面を構築する役割を担っています。
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に反映されるようになります)
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で完全にフォローできました。
Navigator 2.0をタブ切り替え画面に適用させる
では、上記のアプリケーションに追加実装する形で、Home画面をタブ切り替え可能な画面にした場合に、Navigator 2.0をどのように適用すればいいのでしょうか。
おそらく最適解と考えられるのは、Home内にRouter
をもう一つ用意(NestedRouter)し、タブおよびその配下の画面構成をそちらで管理するという方法です。
具体的なコードをベースに見ていきます。以下の仕様を入れ込むことを考えます。
- Home画面にタブを持たせてBooks, Info, Settingsの画面を切り替えできるようにする
- Books画面からはボタン押下でBookDetail画面へ遷移
画面状態管理の更新
まず画面の変更に伴って、 RouteState
を見直します。
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
に持たせます。
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
のやりとりを行わないため、 currencyConfiguration
やsetNewRoutePath()
の実装は不要です。
もちろんその代わり、SampleRoutePath
, SampleRouteInformationPerser
, SampleRouterDelegate
のほうでは新しく追加する各画面に対する設定を追加する必要があります
RoutePathの更新
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の更新
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の更新
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
を持たせる。
// 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を全ての画面に対して適用することができました。
参考資料
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