【Flutter】自動生成型ルーティングパッケージを作った

こんにちは。広瀬マサルです。

Flutterで[go_router](https://pub.dev/packages/go_router)や[auto_route](https://pub.dev/packages/auto_route)などのパッケージを使ってみたんですがどうもしっくり来なかったので自分でルーティングパッケージを作りました。

一言で簡単に言うと

**ページ用のWidgetと同じファイルにルーティング情報を書けるauto_route**

です。

少ないコードで[build_runner](https://pub.dev/packages/build_runner)を使ってコードジェネレーションを行いタイプセーフにルーティングを実装できます。

また、ルーティング情報をWidgetと同じファイルに記載できるので新しいページを書くたびにルーティング用の情報を専用のファイルに追記する必要がなくなり面倒ではなくなります。

使い方をまとめたので興味ある方はぜひ使ってみてください！

# katana_router

https://pub.dev/packages/katana_router

https://pub.dev/packages/katana_router_builder

# はじめに

FlutterのRoutingやNavigatorは使いやすく、[go_router](https://pub.dev/packages/go_router)や[auto_route](https://pub.dev/packages/auto_route)などの素晴らしいパッケージを利用することもできますがそれぞれいくつか不便なところがあります。

- Routeクラスでpushメソッドなどを利用してルーティングした場合、ディープリンクを利用できない。
- ディープリンク（pushNamedなど）を利用した場合、ルーティングパスをStringで直接書く必要がある。またパラメーターを予め把握しておく必要がある。
- 条件元にリダイレクト（AuthGuardなど）する機能。
- **ページのウィジェットを作成したあとにルーティング用の設定を追記する必要があり１ページを作るために２つのDartファイルを編集する必要がある。**

なので、ページ用のWidgetにAnnotationをつけるだけでルーティング用のファイルまで作成してくれるGenerator付きパッケージを作成しました。

このパッケージは下記の機能を有します。

- ディープリンクを利用可能。
    - ディープリンクのパラメーターを利用可能。
- ナビゲーションやパラメーターをタイプセーフで利用可能。
- Widgetをそのまま利用可能。
- 少ない行数でウィジェットをページとして利用可能。
- アプリ内で定義されている全ページを把握し自動的にルーターのクラスを作成可能。
- ネストされたナビゲーションやタブによるナビゲーションをサポート。
    - 現在のページの状態に応じて各種パラメーターを更新可能

このパッケージでは下記の例のようにルーティング設定を実装することが可能です。

## ページ作成

```dart
// home.dart

import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';

part 'home.page.dart';

@PagePath("/")
class HomePage extends StatelessWidget {
  const HomePage();

  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Home")),
      body: Center(
        child: Text("Home page"),
      ),
    );
  }
}
```

## ルーター作成

```dart
// main.dart

import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';

import 'main.router.dart';

@appRoute
final appRouter = AutoRouter();

void main() {
  runApp(const MainPage());
}

class MainPage extends StatelessWidget {
  const MainPage();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: appRouter,
      title: "Test App",
    );
  }
}

```

## ナビゲーション

```dart
// push
context.router.push(HomePage.query());
// pop
context.router.pop();
```

# インストール

[build_runner](https://pub.dev/packages/build_runner)を用いたコードジェネレーションを行うため下記のパッケージをインポートします。

```bash
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のパラメーターはそのまま定義可能です。

```dart
// user.dart

import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';

part 'user.page.dart';

@PagePath("/user/:user_id")
class UserPage extends StatelessWidget {
  const UserPage({
    @PageParam("user_id") required this.userId,
    super.key,
  });

  final String userId;

  @pageRouteQuery
  static const query = _$UserPageQuery();

  @override
  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`に与えてください。そうすることで自動的にルーティング情報がアプリに渡されます。

```dart
// main.dart

import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';

import 'main.router.dart';

@appRoute
final appRouter = AutoRouter();

void main() {
  runApp(const MainPage());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: appRouter,
      title: "Test App",
    );
  }
}

```
## 手動でのルーター利用

AutoRouterを作成しない場合でも`AppRouter`を用いることでルーターを作成することができます。

この場合、`pages`に登録したいページのクエリを渡してください。

routerの自動作成がうまく行かない場合や、ページ登録を制限したい場合などにご利用ください。

```dart
// 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';

@appRoute
final appRouter = AppRouter(
  pages: [
    HomePage.query,
    EditPage.query,
    DetailPage.query,
  ],
);

void main() {
  runApp(const MainPage());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: appRouter,
      title: "Test App",
    );
  }
}
```

## ナビゲーション

`UserPage`へナビゲーションを行うためには下記を実行します。

`query`にはWidgetで定義したパラメーターをそのまま記載可能です。

```dart
// push
context.router.push(UserPage.query(userId: "User id"));
// pop
context.router.pop();
```

# コードジェネレーション

下記のコマンドを入力することで自動でコード生成を行います。

```bash
flutter pub run build_runner build --delete-conflicting-outputs
```

# 応用した使い方

## 初期ページの設定

アプリ起動時の初期ページは`/`のパスに紐付けられたページです。

これを変更したい場合、AppRouterやAutoRouterのオブジェクトを作成する際に`initialPath`を指定することで変更が可能になります。

```dart
@appRoute
final appRouter = AutoRouter(
  initialPath: "/landing"
);
```

initialQueryを指定するとRouteQueryそのもので初期ページを指定することが可能です。

こちらのほうがより安全です。

```dart
@appRoute
final appRouter = AutoRouter(
  initialQuery: HomePage.query(),
);
```

## クエリーパラメーター

Webで利用している場合、クエリーパラメーターを受け取りたいことがあるかと思います。

その場合は`QueryParam`のアノテーションを利用することでクエリーのキーを指定することができます。

下記の場合、`https://myhost.com/search?q=text`でアクセスするとsearchQueryに`text`が渡されます。

```dart
// search.dart

import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';

part 'search.page.dart';

@PagePath("/search")
class SearchPage extends StatelessWidget {
  const SearchPage({
    @QueryParam("q") required this.searchQuery,
    super.key,
  });

  final String searchQuery;

  @pageRouteQuery
  static const query = _$SearchPageQuery();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("User")),
      body: Center(
        child: Text("SearchQuery: $searchQuery"),
      ),
    );
  }
}
```

## リダイレクト

例えばログインしていない場合はログイン画面を表示し、ログイン済みの場合はホームページを表示される場合`RedirectQuery`を利用します。

`RedirectQuery`を継承してredirectのメソッドを実装します。

`source`に元のRouteQueryが渡されるので、そのまま遷移しようとしているページに遷移する場合はそのまま`source`を返します。

他のページに遷移したい場合は、そのページのRouteQueryを渡してください。

```dart
import 'package:katana_router/katana_router.dart';
import 'dart:async';

class LoginRequiredRedirectQuery extends RedirectQuery {
  const LoginRequiredRedirectQuery();
  @override
  FutureOr<RouteQuery> redirect(
      BuildContext context, RouteQuery source) async {
    if (isSignedIn) {
      return source;
    } else {
      return Login.query();
    }
  }
}
```

ここで作成した`RedirectQuery`をPagePath（個別ページごと）もしくはAppRouterやAutoRouter（全ページ）で渡すとそこに実装したリダイレクトの仕組みが適用されます。

```dart
@PagePath(
  "/user/:user_id",
  redirect: [
    LoginRequiredRedirectQuery(),
  ]
)
class UserPage extends StatelessWidget {
  const UserPage({
    @PageParam("user_id") required this.userId,
    super.key,
  });

  final String userId;

  @pageRouteQuery
  static const query = _$UserPageQuery();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("User")),
      body: Center(
        child: Text("User id: $userId"),
      ),
    );
  }
}
```

## アプリ起動時のページ

アプリを起動する際のスプラッシュページを定義することが可能です。

このスプラッシュページで最初のデータロードを行うなどアプリ起動に必要な処理を実行させることが可能です。

`BootRouteQueryBuilder`を継承したクラスを作成し`onInit`（起動時の処理）、`build`（起動時の画面表示）、`initialTransitionQuery`（起動時の画面から最初のページに遷移する際のトランジション）、`onError`（エラー時の処理）を定義します。

```dart
import 'package:katana_router/katana_router.dart';
import 'package:flutter/material.dart';

class AppBoot extends BootRouteQueryBuilder {
  const AppBoot();

  @override
  Future<void> onInit(BuildContext context) async {
    await Future.delayed(const Duration(milliseconds: 1000));
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: const [
        Material(child: AppLogo()),
        CompanyLogo(),
      ],
    );
  }

  @override
  void onError(BuildContext context, Object error, StackTrace stackTrace) {
    Modal.alert(
      context,
      submitText: "Quit",
      title: "Error",
      text: "Initialization failed. \n\n$error\n$stackTrace",
    );
  }

  @override
  TransitionQuery get initialTransitionQuery => TransitionQuery.fade;
}
```

ここで定義した`AppBoot`クラスを`AppRouter()`や`AutoRouter()`クラスで引数として与えます。

```dart
@appRoute
final appRouter = AutoRouter(
  boot: const AppBoot(),
);
```

## Nested Navigation

`AppRouter`を下層のWidgetで管理することでネストされたナビゲーションを実装することができます。

作成したAppRouterは変更されないように`StatefulWidget`や`Provider`などで状態を保持してください。

ネストされたナビゲーションでも`initialQuery`が利用可能です。

さらに`pages`のパラメーターで使用するページを制限することも可能です。

このAppRouterを`Router.withConfig`でそのまま渡すことによりそれより下層のナビゲーションを実装することができます。

```dart
@PagePath("/nested")
class NestedContainerPage extends StatefulWidget {
  const NestedContainerPage({
    super.key,
  });

  @pageRouteQuery
  static const query = _$NestedContainerPageQuery();

  @override
  State<StatefulWidget> createState() => _NestedContainerPageState();
}

class _NestedContainerPageState extends State<NestedContainerPage> {
  final router = AppRouter(
    initialQuery: InnerPage1.query(),
    pages: [
      InnerPage1.query,
      InnerPage2.query,
    ],
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("NestedPage")),
      body: Router.withConfig(config: router),
    );
  }
}
```

ネストされたページは基本的にディープリンク用のパスが必要ありません。

そのため、専用に`NestedPage`アノテーションを利用することが可能です。

```dart
@nestedPage()
class InnerPage1 extends StatelessWidget {
  const InnerPage1({super.key});

  static const query = _$InnerPage1Query();

  @override
  Widget build(BuildContext context) {
    final current = context.rootRouter.currentQuery;
    return Center(
      child: TextButton(
        onPressed: () {
          context.router.push(InnerPage2.query());
        },
        child: Text("To Innerpage2"),
      ),
    );
  }
}

@nestedPage()
class InnerPage2 extends StatelessWidget {
  const InnerPage2({super.key});

  static const query = _$InnerPage2Query();

  @override
  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の値を渡してその値を元にタブの状態を変更」することでタイプセーフにタブナビゲーションを実装することができます。

```dart
@PagePath("/nested", name: "nested")
class NestedContainerPage extends StatefulWidget {
  const NestedContainerPage({
    super.key,
  });

  @pageRouteQuery
  static const query = _$NestedContainerPageQuery();

  @override
  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),
    ],
  );

  @override
  void initState() {
    super.initState();
    router.addListener(handledOnUpdate);
  }

  void handledOnUpdate() {
    setState(() {});
  }

  @override
  void dispose() {
    super.dispose();
    router.removeListener(handledOnUpdate);
    router.dispose();
  }

  @override
  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(),
      ),
    );
  }
}

@NestedPage(key: InnerPageType.type1)
class InnerPage1 extends StatelessWidget {
  const InnerPage1({super.key});

  static const query = _$InnerPage1Query();

  @override
  Widget build(BuildContext context) {
    final current = context.rootRouter.currentQuery;
    return Center(
      child: TextButton(
        onPressed: () {
          context.rootRouter.pop();
        },
        child: Text("To Innerpage2 ${current?.name}"),
      ),
    );
  }
}

@NestedPage(key: InnerPageType.type2)
class InnerPage2 extends StatelessWidget {
  const InnerPage2({super.key});

  static const query = _$InnerPage2Query();

  @override
  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](https://pub.dev/packages/build_runner)を利用したルーティングパッケージには[auto_route](https://pub.dev/packages/auto_route)という素晴らしい既存パッケージがあります。

このパッケージとの比較を行います。

|  | katana_router | auto_route |
| --- | --- | --- |
| ルーティングの記載方法 | WidgetにAnnotationを直接記載。分散して定義。 | Routerを作成しAnnotationにすべて記載。集中して定義。 |
| 生成されるファイル | ページごとに１つ＋ルーターで１つ | ルーターで１つ |
| タイプセーフ | ○ | ○ |
| ディープリンク | ○ | ○ |
| ディープリンクのパラメーター | ○ | ○ |
| リダイレクト | ○ | ○ |
| Nested Navigation | ○ | ○ |

# おわりに

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet