🦂

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

2022/10/16に公開約13,900字

※2022/10/18 NestedNavigationなどの機能を追加したため追記

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

Flutterでgo_routerauto_routeなどのパッケージを使ってみたんですがどうもしっくり来なかったので自分でルーティングパッケージを作りました。

一言で簡単に言うと

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

です。

少ないコードでbuild_runnerを使ってコードジェネレーションを行いタイプセーフにルーティングを実装できます。

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

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

katana_router

https://pub.dev/packages/katana_router
https://pub.dev/packages/katana_router_builder

はじめに

FlutterのRoutingやNavigatorは使いやすく、go_routerauto_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 = _$HomePage();

  
  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 = AppRouter();

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のフィールドを定義することでページ遷移用のクエリを作成することができます。

クエリーの値には_$ウィジェットのクラス名を指定します。

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 = _$UserPage();

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

ルーター作成

import ‘元のファイル名.router.dart’;でライブラリファイルをインポートします。

ルーターを作成するためには@appRouteのAnnotationをトップレベルの値で付与します。

また、その値にAppRouterのオブジェクトを入れてください。

その値をそのままMaterialApp.routerrouterConfigに与えてください。そうすることで自動的にルーティング情報がアプリに渡されます。

// main.dart

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

import 'main.router.dart';


final appRouter = AppRouter();

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のオブジェクトを作成する際にinitialPathを指定することで変更が可能になります。


final appRouter = AppRouter(
  initialPath: "/landing"
);

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

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


final appRouter = AppRouter(
  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 = _$SearchPage();

  
  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(全ページ)で渡すとそこに実装したリダイレクトの仕組みが適用されます。

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

  final String userId;

  
  static const query = _$UserPage();

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

アプリ起動時のページ

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

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

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

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

  
  TransitionQuery get initialTransitionQuery => TransitionQuery.fade;
}

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


final appRouter = AppRouter(
  boot: const AppBoot(),
);

Nested Navigation

作成されたAppRouter(もしくは専用のNestedAppRouter)を下層のWidgetで管理することでネストされたナビゲーションを実装することができます。

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

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

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

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

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

  
  static const query = _$NestedContainerPage();

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

class _NestedContainerPageState extends State<NestedContainerPage> {
  final router = NestedAppRouter(
    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 = _$InnerPage1();

  
  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 = _$InnerPage2();

  
  Widget build(BuildContext context) {
    return Center(
      child: TextButton(
        onPressed: () {
          context.router.push(InnerPage1.query());
        },
        child: Text("To Innerpage1"),
      ),
    );
  }
}

ネストされたページ内でcontext.routerを指定するとネストされたナビゲーション内でページ遷移を行います。

フルスクリーンのページ遷移を行ないたい場合context.rootRouterを用いてください。トップレベルのAppRouterでページ遷移を行うことができます。

タブナビゲーション

AppRouterはChangeNotifierを継承しておりページ遷移を検知するとそれを通知します。

そのため、AppRouterをaddListenerで監視すると下層で起こった遷移を検知しWidget自体を更新することが可能です。

また、現在のページの情報はAppRouter.currentQueryで確認することが可能です。

各ページに対してkeynameが指定可能なため、例えば「keyにenumの値を渡してその値を元にタブの状態を変更」することでタイプセーフにタブナビゲーションを実装することができます。

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

  
  static const query = _$NestedContainerPage();

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

class _NestedContainerPageState extends State<NestedContainerPage> {
  final router = NestedAppRouter(
    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: (value) {
          router.push(
            InnerPageType.values[value].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 = _$InnerPage1();

  
  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 = _$InnerPage2();

  
  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をお待ちしてます!

また仕事の依頼等ございましたら、私のTwitterWebサイトで直接ご連絡をお願いいたします!
https://mathru.net

Discussion

ログインするとコメントできます