【Flutter】自動生成型ルーティングパッケージを作った
※2022/10/18 NestedNavigationなどの機能を追加したため追記
こんにちは。広瀬マサルです。
Flutterでgo_routerやauto_routeなどのパッケージを使ってみたんですがどうもしっくり来なかったので自分でルーティングパッケージを作りました。
一言で簡単に言うと
ページ用のWidgetと同じファイルにルーティング情報を書けるauto_route
です。
少ないコードでbuild_runnerを使ってコードジェネレーションを行いタイプセーフにルーティングを実装できます。
また、ルーティング情報をWidgetと同じファイルに記載できるので新しいページを書くたびにルーティング用の情報を専用のファイルに追記する必要がなくなり面倒ではなくなります。
使い方をまとめたので興味ある方はぜひ使ってみてください!
katana_router
はじめに
FlutterのRoutingやNavigatorは使いやすく、go_routerやauto_routeなどの素晴らしいパッケージを利用することもできますがそれぞれいくつか不便なところがあります。
- Routeクラスでpushメソッドなどを利用してルーティングした場合、ディープリンクを利用できない。
- ディープリンク(pushNamedなど)を利用した場合、ルーティングパスをStringで直接書く必要がある。またパラメーターを予め把握しておく必要がある。
- 条件元にリダイレクト(AuthGuardなど)する機能。
- ページのウィジェットを作成したあとにルーティング用の設定を追記する必要があり1ページを作るために2つのDartファイルを編集する必要がある。
なので、ページ用のWidgetにAnnotationをつけるだけでルーティング用のファイルまで作成してくれるGenerator付きパッケージを作成しました。
このパッケージは下記の機能を有します。
- ディープリンクを利用可能。
- ディープリンクのパラメーターを利用可能。
- ナビゲーションやパラメーターをタイプセーフで利用可能。
- Widgetをそのまま利用可能。
- 少ない行数でウィジェットをページとして利用可能。
- アプリ内で定義されている全ページを把握し自動的にルーターのクラスを作成可能。
- ネストされたナビゲーションやタブによるナビゲーションをサポート。
- 現在のページの状態に応じて各種パラメーターを更新可能
このパッケージでは下記の例のようにルーティング設定を実装することが可能です。
ページ作成
// home.dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
part 'home.page.dart';
("/")
class HomePage extends StatelessWidget {
const HomePage();
static const query = _$HomePageQuery();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Home")),
body: Center(
child: Text("Home page"),
),
);
}
}
ルーター作成
// main.dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
import 'main.router.dart';
final appRouter = AutoRouter();
void main() {
runApp(const MainPage());
}
class MainPage extends StatelessWidget {
const MainPage();
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: appRouter,
title: "Test App",
);
}
}
ナビゲーション
// push
context.router.push(HomePage.query());
// pop
context.router.pop();
インストール
build_runnerを用いたコードジェネレーションを行うため下記のパッケージをインポートします。
flutter pub add katana_router
flutter pub add --dev build_runner
flutter pub add --dev katana_router_builder
実装
ページ作成
ページパス/user/任意のユーザーID
で表示するためのWidgetは下記のように実装します。
part ‘元のファイル名.page.dart’;
でPartファイルをインポートします。
@PagePath(”パス名”)
のAnnotationでWidgetをページとして定義します。
@pageRouteQuery
のAnnotationを付与してqueryのフィールドを定義することでページ遷移用のクエリを作成することができます。
クエリーの値には_$(ウィジェットのクラス名)Query
を指定します。
Widgetのパラメーターはそのまま定義可能です。
// user.dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
part 'user.page.dart';
("/user/:user_id")
class UserPage extends StatelessWidget {
const UserPage({
("user_id") required this.userId,
super.key,
});
final String userId;
static const query = _$UserPageQuery();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("User")),
body: Center(
child: Text("User id: $userId"),
),
);
}
}
ルーター作成
import ‘元のファイル名.router.dart’;
でライブラリファイルをインポートします。
ルーターを作成するためには@appRoute
のAnnotationをトップレベルの値で付与します。
また、その値にAutoRouter
のオブジェクトを入れてください。
その値をそのままMaterialApp.router
のrouterConfig
に与えてください。そうすることで自動的にルーティング情報がアプリに渡されます。
// main.dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
import 'main.router.dart';
final appRouter = AutoRouter();
void main() {
runApp(const MainPage());
}
class MainPage extends StatelessWidget {
const MainPage({super.key});
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: appRouter,
title: "Test App",
);
}
}
手動でのルーター利用
AutoRouterを作成しない場合でもAppRouter
を用いることでルーターを作成することができます。
この場合、pages
に登録したいページのクエリを渡してください。
routerの自動作成がうまく行かない場合や、ページ登録を制限したい場合などにご利用ください。
// main.dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
import 'pages/home.dart';
import 'pages/edit.dart';
import 'pages/detail.dart';
final appRouter = AppRouter(
pages: [
HomePage.query,
EditPage.query,
DetailPage.query,
],
);
void main() {
runApp(const MainPage());
}
class MainPage extends StatelessWidget {
const MainPage({super.key});
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: appRouter,
title: "Test App",
);
}
}
ナビゲーション
UserPage
へナビゲーションを行うためには下記を実行します。
query
にはWidgetで定義したパラメーターをそのまま記載可能です。
// push
context.router.push(UserPage.query(userId: "User id"));
// pop
context.router.pop();
コードジェネレーション
下記のコマンドを入力することで自動でコード生成を行います。
flutter pub run build_runner build --delete-conflicting-outputs
応用した使い方
初期ページの設定
アプリ起動時の初期ページは/
のパスに紐付けられたページです。
これを変更したい場合、AppRouterやAutoRouterのオブジェクトを作成する際にinitialPath
を指定することで変更が可能になります。
final appRouter = AutoRouter(
initialPath: "/landing"
);
initialQueryを指定するとRouteQueryそのもので初期ページを指定することが可能です。
こちらのほうがより安全です。
final appRouter = AutoRouter(
initialQuery: HomePage.query(),
);
クエリーパラメーター
Webで利用している場合、クエリーパラメーターを受け取りたいことがあるかと思います。
その場合はQueryParam
のアノテーションを利用することでクエリーのキーを指定することができます。
下記の場合、https://myhost.com/search?q=text
でアクセスするとsearchQueryにtext
が渡されます。
// search.dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
part 'search.page.dart';
("/search")
class SearchPage extends StatelessWidget {
const SearchPage({
("q") required this.searchQuery,
super.key,
});
final String searchQuery;
static const query = _$SearchPageQuery();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("User")),
body: Center(
child: Text("SearchQuery: $searchQuery"),
),
);
}
}
リダイレクト
例えばログインしていない場合はログイン画面を表示し、ログイン済みの場合はホームページを表示される場合RedirectQuery
を利用します。
RedirectQuery
を継承してredirectのメソッドを実装します。
source
に元のRouteQueryが渡されるので、そのまま遷移しようとしているページに遷移する場合はそのままsource
を返します。
他のページに遷移したい場合は、そのページのRouteQueryを渡してください。
import 'package:katana_router/katana_router.dart';
import 'dart:async';
class LoginRequiredRedirectQuery extends RedirectQuery {
const LoginRequiredRedirectQuery();
FutureOr<RouteQuery> redirect(
BuildContext context, RouteQuery source) async {
if (isSignedIn) {
return source;
} else {
return Login.query();
}
}
}
ここで作成したRedirectQuery
をPagePath(個別ページごと)もしくはAppRouterやAutoRouter(全ページ)で渡すとそこに実装したリダイレクトの仕組みが適用されます。
(
"/user/:user_id",
redirect: [
LoginRequiredRedirectQuery(),
]
)
class UserPage extends StatelessWidget {
const UserPage({
("user_id") required this.userId,
super.key,
});
final String userId;
static const query = _$UserPageQuery();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("User")),
body: Center(
child: Text("User id: $userId"),
),
);
}
}
アプリ起動時のページ
アプリを起動する際のスプラッシュページを定義することが可能です。
このスプラッシュページで最初のデータロードを行うなどアプリ起動に必要な処理を実行させることが可能です。
BootRouteQueryBuilder
を継承したクラスを作成しonInit
(起動時の処理)、build
(起動時の画面表示)、initialTransitionQuery
(起動時の画面から最初のページに遷移する際のトランジション)、onError
(エラー時の処理)を定義します。
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';
class AppBoot extends BootRouteQueryBuilder {
const AppBoot();
Future<void> onInit(BuildContext context) async {
await Future.delayed(const Duration(milliseconds: 1000));
}
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: const [
Material(child: AppLogo()),
CompanyLogo(),
],
);
}
void onError(BuildContext context, Object error, StackTrace stackTrace) {
Modal.alert(
context,
submitText: "Quit",
title: "Error",
text: "Initialization failed. \n\n$error\n$stackTrace",
);
}
TransitionQuery get initialTransitionQuery => TransitionQuery.fade;
}
ここで定義したAppBoot
クラスをAppRouter()
やAutoRouter()
クラスで引数として与えます。
final appRouter = AutoRouter(
boot: const AppBoot(),
);
Nested Navigation
AppRouter
を下層のWidgetで管理することでネストされたナビゲーションを実装することができます。
作成したAppRouterは変更されないようにStatefulWidget
やProvider
などで状態を保持してください。
ネストされたナビゲーションでもinitialQuery
が利用可能です。
さらにpages
のパラメーターで使用するページを制限することも可能です。
このAppRouterをRouter.withConfig
でそのまま渡すことによりそれより下層のナビゲーションを実装することができます。
("/nested")
class NestedContainerPage extends StatefulWidget {
const NestedContainerPage({
super.key,
});
static const query = _$NestedContainerPageQuery();
State<StatefulWidget> createState() => _NestedContainerPageState();
}
class _NestedContainerPageState extends State<NestedContainerPage> {
final router = AppRouter(
initialQuery: InnerPage1.query(),
pages: [
InnerPage1.query,
InnerPage2.query,
],
);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("NestedPage")),
body: Router.withConfig(config: router),
);
}
}
ネストされたページは基本的にディープリンク用のパスが必要ありません。
そのため、専用にNestedPage
アノテーションを利用することが可能です。
()
class InnerPage1 extends StatelessWidget {
const InnerPage1({super.key});
static const query = _$InnerPage1Query();
Widget build(BuildContext context) {
final current = context.rootRouter.currentQuery;
return Center(
child: TextButton(
onPressed: () {
context.router.push(InnerPage2.query());
},
child: Text("To Innerpage2"),
),
);
}
}
()
class InnerPage2 extends StatelessWidget {
const InnerPage2({super.key});
static const query = _$InnerPage2Query();
Widget build(BuildContext context) {
return Center(
child: TextButton(
onPressed: () {
context.router.push(InnerPage1.query());
},
child: Text("To Innerpage1"),
),
);
}
}
ネストされたページ内でcontext.router
を指定するとネストされたナビゲーション内でページ遷移を行います。
フルスクリーンのページ遷移を行ないたい場合context.rootRouter
を用いてください。トップレベルのAppRouterやAutoRouterでページ遷移を行うことができます。
タブナビゲーション
AppRouterやAutoRouterはChangeNotifier
を継承しておりページ遷移を検知するとそれを通知します。
そのため、AppRouterやAutoRouterをaddListener
で監視すると下層で起こった遷移を検知しWidget自体を更新することが可能です。
また、現在のページの情報はAppRouter.currentQuery
で確認することが可能です。
各ページに対してkey
やname
が指定可能なため、例えば「keyにenumの値を渡してその値を元にタブの状態を変更」することでタイプセーフにタブナビゲーションを実装することができます。
("/nested", name: "nested")
class NestedContainerPage extends StatefulWidget {
const NestedContainerPage({
super.key,
});
static const query = _$NestedContainerPageQuery();
State<StatefulWidget> createState() => _NestedContainerPageState();
}
class _NestedContainerPageState extends State<NestedContainerPage> {
final router = AppRouter(
initialQuery: InnerPage1.query(),
defaultTransitionQuery: TransitionQuery.fade,
pages: [
...InnerPageType.values.map((e) => e.builder),
],
);
void initState() {
super.initState();
router.addListener(handledOnUpdate);
}
void handledOnUpdate() {
setState(() {});
}
void dispose() {
super.dispose();
router.removeListener(handledOnUpdate);
router.dispose();
}
Widget build(BuildContext context) {
final query = router.currentQuery;
return Scaffold(
appBar: AppBar(title: const Text("NestedPage")),
body: Router.withConfig(config: router),
bottomNavigationBar: BottomNavigationBar(
onTap: (index) {
router.push(
InnerPageType.values[index].query,
);
},
currentIndex: query?.key<InnerPageType>()?.index ?? 0,
items: InnerPageType.values.map((type) {
return BottomNavigationBarItem(
icon: Icon(type.icon),
label: type.label,
);
}).toList(),
),
);
}
}
(key: InnerPageType.type1)
class InnerPage1 extends StatelessWidget {
const InnerPage1({super.key});
static const query = _$InnerPage1Query();
Widget build(BuildContext context) {
final current = context.rootRouter.currentQuery;
return Center(
child: TextButton(
onPressed: () {
context.rootRouter.pop();
},
child: Text("To Innerpage2 ${current?.name}"),
),
);
}
}
(key: InnerPageType.type2)
class InnerPage2 extends StatelessWidget {
const InnerPage2({super.key});
static const query = _$InnerPage2Query();
Widget build(BuildContext context) {
return Center(
child: TextButton(
onPressed: () {
context.router.push(InnerPage1.query());
},
child: const Text("To Innerpage1"),
),
);
}
}
enum InnerPageType {
type1(
icon: Icons.people,
label: "people",
),
type2(
icon: Icons.settings,
label: "settings",
);
const InnerPageType({
required this.icon,
required this.label,
});
final IconData icon;
final String label;
RouteQueryBuilder get builder {
switch (this) {
case InnerPageType.type1:
return InnerPage1.query;
case InnerPageType.type2:
return InnerPage2.query;
}
}
RouteQuery get query {
switch (this) {
case InnerPageType.type1:
return InnerPage1.query();
case InnerPageType.type2:
return InnerPage2.query();
}
}
}
vs auto_route
build_runnerを利用したルーティングパッケージにはauto_routeという素晴らしい既存パッケージがあります。
このパッケージとの比較を行います。
katana_router | auto_route | |
---|---|---|
ルーティングの記載方法 | WidgetにAnnotationを直接記載。分散して定義。 | Routerを作成しAnnotationにすべて記載。集中して定義。 |
生成されるファイル | ページごとに1つ+ルーターで1つ | ルーターで1つ |
タイプセーフ | ○ | ○ |
ディープリンク | ○ | ○ |
ディープリンクのパラメーター | ○ | ○ |
リダイレクト | ○ | ○ |
Nested Navigation | ○ | ○ |
おわりに
自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください!
また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!
また仕事の依頼等ございましたら、私のTwitterやWebサイトで直接ご連絡をお願いいたします!
GitHub Sponsors
スポンサーを随時募集してます。ご支援お待ちしております!
Discussion