Inlayのナビゲーション

ネイティブ画面とFlutter画面の間を、双方向に型安全にナビゲーションします。

Inlayは、ネイティブ(iOS/Android)画面とFlutter画面の間で、双方向の型安全なナビゲーションを提供します。 ルートはアノテーション付きのDartクラスとして一度だけ定義し、ジェネレーターがDart、Swift、Kotlin向けに 対応する型を生成します。そのため、すべてのナビゲーション呼び出しはコンパイル時にチェックされ、 境界を任意の文字列が行き交うことはありません。

このフレームワークはマルチエンジン方式を採用しています。ネイティブコードからFlutter画面へ遷移すると、 独自のFlutterエンジンを持つ新しいネイティブコンテナ(AndroidではActivity、iOSではViewController)が 作成されます。InlayNavigator経由でFlutterルートをpushした場合も同様です。これはネイティブコードからでも、 Flutterから明示的に新しいコンテナを開く場合(例:pushForResult())でも変わりません。一方、Flutter側の ルーターが処理するpushは現在のコンテナ内にとどまり、そのエンジンとナビゲーションスタックを共有します。 すべてのエンジンはFlutterEngineGroupを介してリソースを共有するため、新しいエンジンの起動コストは小さく、 エンジンを直接管理する必要もありません。

Flutterエンジンやマルチエンジンモデルについて初めて知る方は、基礎となる理論を コアコンセプトで確認してください。このページではナビゲーションAPIに 焦点を当てます。

コンセプト

  • Flutterルート - Flutterが描画する画面です。ネイティブコードから遷移すると、ネイティブコンテナ (Activity / ViewController)内に新しいエンジンが作成されます。Flutter内からルーター経由でpushした場合は 既存エンジンのナビゲーションスタックを使い、InlayNavigator経由でpushした場合は新しいコンテナと エンジンが開かれます。
  • Flutterダイアログルート - ネイティブ画面の上にFlutterが描画するダイアログ、ボトムシート、または アクションシートです。ネイティブ側は透明なコンテナを開くため、下の画面は見えたままになり、Flutterが オーバーレイの内容(バリア、アニメーション、配置)を描画します。通常のルートと同じく、コンテナごとに エンジンを持つモデルを使います。
  • ネイティブルート - ネイティブプラットフォームが描画する画面です。Flutterはそこへの遷移を要求でき、 どのように表示するかはネイティブ側が決めます。
  • InlayNavigator - 境界をまたぐすべてのナビゲーションを統括するシングルトンです。 InlayNavigator.instance(Dart)、InlayNavigator.shared(iOS)、またはInlayNavigator オブジェクト(Android)としてアクセスします。
  • PageSettings - routeId、任意のparams、任意のpath、そしてプラットフォーム境界をまたいだ ドリフト検出に使うschemaFingerprintを 保持するデータクラスです。生成されたルートクラスがこれを作成するため、直接触ることはほとんどありません。

ルートの定義

ルートはアノテーション付きの普通のDartクラスです。コードジェネレーターを実行すると、Dart、Swift、Kotlin向けの 型安全なルートクラスが生成されるため、すべてのプラットフォームでコンパイル時の安全性が得られます。 ここでは3種類のルートを順に見ていきます。

Flutterルート

@InlayFlutterRouteでアノテーションし、パステンプレートを指定します。パスは、新しいエンジンの起動時に Flutterのルーティングライブラリ(go_router、auto_routeなど)へ渡されるURLを決めます。フレームワークは これによって、どの画面を表示するかをルーターに伝えます。パスパラメーター(:param)は、クラス上の 同名のフィールドに対応します。これらのフィールドはrequiredにしておいてください。ジェネレーターはこれを 強制しませんが、nullのパスパラメーターは空のパスセグメントとしてエンコードされてしまいます。

@InlayFlutterRoute('/contact-details/:contactId')
class ContactDetailsPage {
  const ContactDetailsPage({
    required this.contactId,
    this.badges,
  });

  final String contactId;
  final List<ContactBadge>? badges;
}

ジェネレーターが生成するもの:

  • 生成されるsealed class FlutterRoute(それ自体はFlutterRouteBaseを継承)を継承したDartクラス。 シリアライズが組み込まれており、網羅的なパターンマッチングが可能です。
  • 同じフィールドを持つSwiftのstruct / Kotlinのdata class。ネイティブコードから直接使えます。

Flutterダイアログルート

Flutterが描画し、ネイティブ画面の上に表示するオーバーレイ(ダイアログ、ボトムシート、アクションシート)には @InlayFlutterDialogでアノテーションします。ネイティブ側は透明なコンテナを開くため、下の画面は 見えたままになり、Flutterがダイアログの内容を描画します。

@InlayFlutterDialog('/confirm-delete/:itemId')
class ConfirmDeleteDialog {
  const ConfirmDeleteDialog({required this.itemId, this.title});

  final String itemId;
  final String? title;
}

ジェネレーターが生成するもの:

  • 生成されるsealed class FlutterDialogRoute(FlutterDialogRouteBaseを継承)を継承したDartクラス。 FlutterRouteとは別の型なので、パターンマッチングでページとダイアログを区別できます。
  • FlutterDialogRouteに準拠したSwiftのstruct / FlutterDialogRouteを実装したKotlinのdata class。

スキーマ上、ダイアログルートはページルートとまったく同じように扱われます。パステンプレートを持ち、 パスパラメーターとクエリパラメーターをサポートし、同じシリアライズが適用されます。唯一の違いは、 ネイティブ側での表示方法(不透明なコンテナではなく透明なコンテナ)です。

ネイティブルート

ネイティブ側にある画面には@InlayNativeRouteでアノテーションします。

@InlayNativeRoute()
class NativeEditProfilePage {
  const NativeEditProfilePage({required this.contactId});
  final String contactId;
}

ジェネレーターが生成するもの:

  • InlayNavigator.instance.push()で使うtoNativeRoute()メソッドを持つDartクラス。
  • 生成されるNativeRouteHandler基底クラス(SwiftとKotlin)内の型付きハンドラーメソッド。これにより、 ネイティブ側のナビゲーションを完全に型安全に実装できます。

すべてのナビゲーションはInlayNavigatorを経由します。プラットフォームを選んでください:

// Open a Flutter screen (creates a new native container + engine)
await InlayNavigator.instance.push(
  ContactDetailsPage(contactId: 'abc-123'),
);

// Open a native screen
await InlayNavigator.instance.push(
  NativeEditProfilePage(contactId: 'abc-123').toNativeRoute(),
);

// Close the current screen (dismisses the native container)
await InlayNavigator.instance.pop();

Jetpack Compose向けのヘルパーは、オプションのinlay_composeプラグインに含まれています。追加方法は はじめにを参照してください。Composeを使わないプロジェクトは inlayのみに依存し、InlayNavigator.push(...) / InlayNavigator.createFragment(...)を直接使います。

ホストActivityでのコールバック転送(Android)

InlayFlutterFragmentはFlutterのFlutterFragmentを継承しており、ホストActivityが7つのコールバックを 転送する必要があります。転送しないと、ディープリンク、戻る操作、ユーザー離脱イベント、メモリトリミングが Flutterに届きません。これは、InlayFlutterFragmentを直接ホストする場合、InlayFlutterScreen (inlay_compose由来)経由の場合、InlayFlutterDialogFragment経由の場合のいずれでも、あらゆるActivityに 当てはまります。

最も簡単なのはInlayFlutterHostActivityを継承する方法で、転送処理を自動で組み込んでくれます。 別の基底クラス(例えばAppCompatActivity)が必要な場合は、自前のActivityのオーバーライドから InlayFragmentHostDelegateを使ってください。

ダイアログ

ダイアログルートは透明なネイティブコンテナで表示されるため、下の画面は見えたままになります。 ダイアログの内容、バリア、アニメーションはFlutterが描画します。Flutterからダイアログを表示するのは 通常のpush()だけです。フレームワークがflutterDialogというルートタイプを検出し、ネイティブ側で pushの代わりにpresentDialogを使います。

// Present a dialog over the current native screen
await InlayNavigator.instance.push(
  ConfirmDeleteDialog(itemId: '42'),
);

ネイティブルートの処理(Flutter → ネイティブ)

Flutterがネイティブルートをpushすると、フレームワークはそれをネイティブ側で登録したNativeRouteHandlerに ディスパッチします。生成されたハンドラーには@InlayNativeRouteごとに型付きメソッドがあるため、 文字列をパースする必要はありません。

// Register once (e.g. in AppDelegate)
InlayNavigator.shared.setNativeRouteHandler(MyNativeRouteHandler())

// Implement the generated handler. Each method receives the typed route
// object (`page`) - read its fields directly, no string parsing.
class MyNativeRouteHandler: NativeRouteHandler {
  override func onNativeEditProfile(
    page: NativeEditProfilePage,
    viewController: UIViewController
  ) {
    let vc = EditProfileViewController(contactId: page.contactId)
    viewController.navigationController?.pushViewController(vc, animated: true)
  }
}

画面から結果を返す

何かを決めるために開く画面(確認ダイアログ、ピッカー、ネイティブのフォーム)は、開いた側へ値を返す必要が あることがよくあります。ルート上でresult:を使って結果の型を一度宣言すれば、ジェネレーターが両側の 型付きの受け渡し処理を生成します:

lib/inlay/routes.dart
@InlayFlutterDialog('/confirm-action/:action', result: bool)
class ConfirmActionDialog {
  const ConfirmActionDialog({required this.action});
  final String action;
}

// Native screen reachable from Flutter that returns feedback text.
@InlayNativeRoute(result: String)
class NativeAboutPage {
  const NativeAboutPage({required this.appVersion});
  final String appVersion;
}

結果の型には、ジェネレーターがサポートする任意の型(プリミティブ、enum、アノテーション付きのデータクラス)を 使えます。結果の型はスキーマフィンガープリントの一部になるため、ホストと モジュールで食い違いがあれば、他のドリフトと同じように検出されます。ネイティブ側では、結果の型を持つルートは 通常のFlutterRoute / FlutterDialogRouteではなく、FlutterRouteWithResult / FlutterDialogRouteWithResult(Swiftのプロトコル、Kotlinのジェネリックインターフェース)として生成されます。 どちらも通常の型を継承しており、結果の型パラメーターによって、後述するonResultコールバックに静的な型が 付きます。明示的な結果を返さずに閉じられた画面(戻るジェスチャー、バリアのタップ、システムの戻る操作)は、 呼び出し元にnullをちょうど一度だけ届けます。

ネイティブ → Flutter:Flutter画面の結果を待つ

ネイティブのpush / presentDialogにはonResultコールバックが追加されます。このコールバックは型付きです。 フレームワークが内部で値をデコードするため、宣言した結果の型(ここではBool? / Boolean?)を直接受け取り、 結果なしで画面が閉じられた場合はnil / nullを受け取ります:

InlayNavigator.shared.presentDialog(
  from: self,
  route: ConfirmActionDialog(action: "delete"),
  onResult: { confirmed in
    // confirmed: Bool?
    if confirmed == true { /* ... */ }
  }
)

Flutter側では、画面が値を返します。ダイアログ/ボトムシートのルートでは、生成されたencodeResultを ページに渡し、値を指定してpopします。フルスクリーンの画面では、生成されたpopWithResultを呼び出します:

// Dialog via a declarative router:
InlayDialogPage<bool>(
  encodeResult: ConfirmActionDialog.encodeResult,
  builder: (context) => AlertDialog(
    actions: [
      TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
      TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Confirm')),
    ],
  ),
)

// Full screen (returns the value and closes its container):
CounterPage.popWithResult(currentCount);

Flutter → ネイティブ(またはFlutter → Flutter):結果を待つ

生成されたルートクラスには、型付きの値を返すpushForResult()が追加されます:

// Flutter -> native screen -> String?
final feedback = await NativeAboutPage(appVersion: '1.0.0').pushForResult();

// Flutter -> Flutter in a new engine -> int?
final count = await const CounterPage().pushForResult();

ネイティブ側では、結果の型を持つルートに対応する、生成されたNativeRouteHandlerのメソッドに completionが追加されます。型付きの値を渡してこれを呼び出します:

override func onNativeAbout(
  page: NativeAboutPage,
  viewController: UIViewController,
  completion: @escaping (String) -> Void
) {
  let vc = AboutViewController(version: page.appVersion, onFeedback: completion)
  viewController.navigationController?.pushViewController(vc, animated: true)
}

コールバックはプロセスのメモリ上に保持されます。開いた画面が表示されている間にAndroidがプロセスを終了すると、 コールバックは復元されません。そのため、結果が必ず届くことを前提にしないでください。プロセス終了後も 残す必要があるものは、ストアに保持してください。

Flutterルーターとの統合

ネイティブ側がFlutter画面を開くと、フレームワークはルートをエンコードして新しいFlutterエンジンに渡します。 Dart側はそれを正しいウィジェットに解決します。サポートしている統合スタイルは2つです:

  1. 宣言的 - MaterialApp.routerや、Navigator 2.0に対応した任意のルーティングライブラリ (go_router、auto_routeなど)で動作します。フレームワークは初期ルートをURLパスとして渡し、ルーターが それを画面にマッチさせます。ダイアログルートにはInlayDialogPage / InlayBottomSheetPageを使います。
  2. 命令的 - ルーティングライブラリを使わない通常のMaterialAppで動作します。生成される sealed class FlutterRoute / sealed class FlutterDialogRouteの階層により、Dartのパターンマッチングで 網羅的かつ型安全にルートを解決できます。ダイアログルートにはrunInlayDialogを使います。

宣言的な統合ポイント

フレームワークはルートをURLパス(例:/contact-details/abc-123)としてエンコードし、新しいエンジンの 初期ルートとして渡します。統合ポイントは2つあります:

  • InlayNavigator.initialPath - プラットフォームのdefaultRouteNameを読み取り、URLパスを返します。 これを初期ロケーションとしてルーティングライブラリに渡してください。同期的に動作し、パスパラメーターに加えて シンプルなフィールド(プリミティブ、enum)をクエリパラメーターとして含みます。複雑なフィールドは パスに含まれません。
  • InlayNavigator.fetchInitialRoute(decoder) - ネイティブホストから完全なルートデータ(リストやネストした オブジェクトなどの複雑なフィールドを含む)を取得し、型付きのルートオブジェクトにデコードします。 ページルートだけの場合はdecodeFlutterRouteDataを、ページとダイアログの両方がある場合は decodeInlayRouteDataを使います。

ルートがシンプルなパラメーターしか使わないなら、initialPathだけで十分です。複雑なフィールド (例:List<ContactBadge>? badges)を持つルートが1つでもあるなら、両方が必要です。

2つのラッパーがナビゲーションスタック同士を連携させます:

  • InlayBackButtonDispatcher - ユーザーが戻るを押したときにルーターのスタックが空であれば、何もしない 代わりにネイティブコンテナ(Activity/ViewController)を閉じます。
  • InlayNativePopGestureObserver - iOSのインタラクティブなスワイプバックジェスチャーを、Flutterの ナビゲーションスタックと同期させます。これがないと、Flutter内にpopできるルートが残っていても、 ネイティブのスワイプバックでFlutterコンテナ全体が閉じてしまう可能性があります。MaterialApp.routerの builderを使って、ルーターのchildをこれでラップしてください。
GoRouter createRouter({
  String initialLocation = '/',
  InlayRoute? initialExtra,
}) {
  return GoRouter(
    initialLocation: initialLocation,
    initialExtra: initialExtra,
    routes: [
      GoRoute(
        path: '/sounds-notifications/:contactId',
        builder: (_, state) => SoundsNotificationsScreen(
          contactId: state.pathParameters['contactId']!,
        ),
      ),
      GoRoute(
        path: '/contact-details/:contactId',
        builder: (_, state) {
          final page = state.extra is ContactDetailsPage
              ? state.extra! as ContactDetailsPage
              : null;
          return ContactDetailsScreen(
            contactId: state.pathParameters['contactId']!,
            badges: page?.badges,
          );
        },
      ),
      // Dialog routes use pageBuilder with InlayDialogPage
      GoRoute(
        path: '/confirm-delete/:itemId',
        pageBuilder: (_, state) {
          final dialog = state.extra is ConfirmDeleteDialog
              ? state.extra! as ConfirmDeleteDialog
              : null;
          return InlayDialogPage(
            builder: (_) => ConfirmDeleteContent(
              itemId: dialog?.itemId ?? state.pathParameters['itemId']!,
            ),
          );
        },
      ),
      // Bottom sheet routes use InlayBottomSheetPage
      GoRoute(
        path: '/theme-picker/:userId',
        pageBuilder: (_, state) => InlayBottomSheetPage(
          isScrollControlled: true,
          showDragHandle: true,
          builder: (_) => ThemePickerContent(
            userId: state.pathParameters['userId']!,
          ),
        ),
      ),
    ],
  );
}

@pragma('vm:entry-point')
void inlayMain() async {
  WidgetsFlutterBinding.ensureInitialized();
  await KeyValueStorage.instance.init();

  final path = InlayNavigator.initialPath;
  // Use decodeInlayRouteData (combined decoder) when you have both
  // page routes and dialog routes. It tries page routes first, then dialogs.
  final route = await InlayNavigator.fetchInitialRoute(
    decodeInlayRouteData,
  );
  final router = createRouter(
    initialLocation: path,
    initialExtra: route,
  );

  // Dialog routes need a transparent scaffold background
  final isDialog = route is FlutterDialogRouteBase;

  runApp(
    MaterialApp.router(
      theme: isDialog
          ? ThemeData.light().copyWith(
              scaffoldBackgroundColor: Colors.transparent,
            )
          : ThemeData.light(),
      routeInformationProvider: router.routeInformationProvider,
      routeInformationParser: router.routeInformationParser,
      routerDelegate: router.routerDelegate,
      backButtonDispatcher: InlayBackButtonDispatcher(),
      builder: (_, child) => InlayNativePopGestureObserver(
        child: child ?? const SizedBox.shrink(),
      ),
    ),
  );
}

go_routerのダイアログルートでは、builderではなくpageBuilderを使ってください。これにより、 InlayDialogPage(AlertDialog風のオーバーレイ。barrierDismissible/barrierColorを設定可能)や InlayBottomSheetPage(モーダルシート。isScrollControlled、isDismissible、enableDrag、 showDragHandleを設定可能)を返せます。どちらも閉じられると自動的にInlayNavigator.instance.pop()を 呼び出し、ネイティブの透明なコンテナを閉じます。ネイティブ画面が透けて見えるように、ダイアログ用エンジンでは MaterialAppのテーマでscaffoldBackgroundColor: Colors.transparentを設定する必要があります。

命令的(sealed class + パターンマッチング)

宣言的なルーターを使わない場合は、PageSettingsを型付きのFlutterRouteにデコードし、switch式で 網羅的かつ型安全にルートを解決します。間に文字列やハンドラークラスのボイラープレートは挟まりません。

仕組み:

  1. ジェネレーターはsealed class FlutterRoute(ページ用)と、必要に応じてsealed class FlutterDialogRoute (ダイアログ用)を生成します。各@InlayFlutterRoute / @InlayFlutterDialogは、それぞれのsealed型の サブクラスになります。
  2. 生成されたdecodeInlayRouteData関数が、PageSettingsをどちらかの型にデコードします (戻り値はInlayRoute?)。
  3. InlayNavigator.fetchInitialRoute(decodeInlayRouteData)で型付きのルートを取得し、パターンマッチングします。 網羅性はコンパイラーが保証します。新しいルートを追加すると、それを処理するまでコンパイルエラーになります。
@pragma('vm:entry-point')
void inlayMain() async {
  WidgetsFlutterBinding.ensureInitialized();
  await KeyValueStorage.instance.init();

  final route = await InlayNavigator.fetchInitialRoute(
    decodeInlayRouteData,
  );

  switch (route) {
    // Page routes - use a regular MaterialApp
    case ContactDetailsPage(:final contactId):
      runApp(MaterialApp(
        home: ContactDetailsScreen(contactId: contactId),
      ));
    case SoundsNotificationsPage(:final contactId):
      runApp(MaterialApp(
        home: SoundsNotificationsScreen(contactId: contactId),
      ));

    // Dialog routes - use runInlayDialog with standard Flutter APIs
    case ConfirmDeleteDialog(:final itemId, :final title):
      runInlayDialog(
        onReady: (context) => showDialog(
          context: context,
          builder: (_) => ConfirmDeleteContent(itemId: itemId, title: title),
        ),
      );
    case ThemePickerDialog(:final userId):
      runInlayDialog(
        onReady: (context) => showModalBottomSheet(
          context: context,
          isScrollControlled: true,
          builder: (_) => ThemePickerContent(userId: userId),
        ),
      );

    case null:
      runApp(const MaterialApp(home: HomeScreen()));
  }
}

runInlayDialogは透明なMaterialAppをセットアップし、最初のフレームの後にonReadyコールバックを 呼び出します。ダイアログ/シートが閉じられると、ネイティブの透明なコンテナも自動的に閉じられます。 コールバック内では標準のFlutter API(showDialog、showModalBottomSheetなど)を使います。 フレームワークが特別なダイアログウィジェットを強制することはありません。

ここではsealed classの階層がほとんどの仕事をしてくれます。パターンマッチングは網羅的です。新しい @InlayFlutterRoute / @InlayFlutterDialogを追加するとswitchが網羅的でなくなるため、新しいケースを 処理するまでコンパイルエラーになります。パターン内でルートのフィールドを直接分割代入でき (例:ConfirmDeleteDialog(:final itemId))、継承すべきハンドラークラスや実装すべきインターフェースも ありません。統合全体が1つのswitch式で完結します。未知のルートにも対応しています。decodeInlayRouteDataは 認識できないルートID(プリウォーム用エンジンのプレースホルダーを含む)に対してnullを返すので、 それをnull分岐で処理します。

この方式では宣言的なルーターが関与しないため、MaterialApp.routerではなく通常のMaterialAppを使います。

内部の仕組み

  1. ネイティブの初期化 - start() / init()がFlutterEngineGroupを作成し、必要に応じて エンジンを1つプリウォームします。
  2. Push - フレームワークはPageSettingsをURL文字列にエンコードし、グループから新しいエンジンを作成して、 ネイティブコンテナ(Activity / ViewController)内に表示します。
  3. ダイアログの表示 - ダイアログルートの場合、ネイティブ側は不透明なコンテナではなく透明なコンテナ (AndroidではDialogFragment、iOSでは.overFullScreenのモーダル)を作成します。エンジンは同じ方法で 起動しますが、Flutterは下に見えているネイティブ画面の上に描画します。
  4. Dartのエントリーポイント - すべてのエンジンは同じエントリーポイント(デフォルトではinlayMain)を 実行します。ルーティングライブラリが初期パスを読み取るか、命令的な方式ではPageSettingsをsealedな ルートにデコードしてパターンマッチングします。
  5. Pop - ネイティブコンテナを閉じると、エンジンが破棄され、プラットフォームチャネルの登録が クリーンアップされます。ダイアログの場合は、ダイアログ/シートが閉じられたときに InlayNavigator.instance.pop()が自動的に呼び出されます。

ルートは次の形式でシリアライズされます:

/path/with/:params?extraParam=value

Dart側はこれをInlayNavigator.initialPathでデコードします。

プライバシーポリシー

このページの内容