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.(Dart)、instance InlayNavigator.(iOS)、またはshared 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. / InlayNavigator.を直接使います。
ホストActivityでのコールバック転送(Android)
InlayFlutterFragmentはFlutterのFlutterFragmentを継承しており、ホストActivityが7つのコールバックを
転送する必要があります。転送しないと、ディープリンク、戻る操作、ユーザー離脱イベント、メモリトリミングが
Flutterに届きません。これは、InlayFlutterFragmentを直接ホストする場合、InlayFlutterScreen
(inlay_compose由来)経由の場合、InlayFlutterDialogFragment経由の場合のいずれでも、あらゆるActivityに
当てはまります。
最も簡単なのはInlayFlutterHostActivityを継承する方法で、転送処理を自動で組み込んでくれます。
別の基底クラス(例えばAppCompatActivity)が必要な場合は、自前のActivityのオーバーライドから
InlayFragmentHostDelegateを使ってください。
class MyActivity : AppCompatActivity() {
private val inlayHost by lazy { InlayFragmentHostDelegate(this) }
override fun onPostResume() { super.onPostResume(); inlayHost.onPostResume() }
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); inlayHost.onNewIntent(intent) }
override fun onBackPressed() {
if (!inlayHost.onBackPressed()) super.onBackPressed()
}
override fun onUserLeaveHint() { super.onUserLeaveHint(); inlayHost.onUserLeaveHint() }
override fun onTrimMemory(level: Int) { super.onTrimMemory(level); inlayHost.onTrimMemory(level) }
override fun onActivityResult(r: Int, rc: Int, d: Intent?) {
super.onActivityResult(r, rc, d); inlayHost.onActivityResult(r, rc, d)
}
override fun onRequestPermissionsResult(r: Int, p: Array<out String>, g: IntArray) {
super.onRequestPermissionsResult(r, p, g); inlayHost.onRequestPermissionsResult(r, p, g)
}
}デリゲートはFragmentツリー全体をたどるため、ネストされたInlayFlutterFragment
(InlayFlutterDialogFragment内部のものを含む)も自動的にカバーされます。
ダイアログ
ダイアログルートは透明なネイティブコンテナで表示されるため、下の画面は見えたままになります。
ダイアログの内容、バリア、アニメーションは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:を使って結果の型を一度宣言すれば、ジェネレーターが両側の
型付きの受け渡し処理を生成します:
@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つです:
- 宣言的 -
MaterialApp.や、Navigator 2.0に対応した任意のルーティングライブラリ (go_router、auto_routeなど)で動作します。フレームワークは初期ルートをURLパスとして渡し、ルーターが それを画面にマッチさせます。ダイアログルートにはrouter InlayDialogPage/InlayBottomSheetPageを使います。 - 命令的 - ルーティングライブラリを使わない通常の
MaterialAppで動作します。生成されるsealed class FlutterRoute/sealed class FlutterDialogRouteの階層により、Dartのパターンマッチングで 網羅的かつ型安全にルートを解決できます。ダイアログルートにはrunInlayDialogを使います。
宣言的な統合ポイント
フレームワークはルートをURLパス(例:/)としてエンコードし、新しいエンジンの
初期ルートとして渡します。統合ポイントは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.を
呼び出し、ネイティブの透明なコンテナを閉じます。ネイティブ画面が透けて見えるように、ダイアログ用エンジンでは
MaterialAppのテーマでscaffoldBackgroundColor: Colors.を設定する必要があります。
命令的(sealed class + パターンマッチング)
宣言的なルーターを使わない場合は、PageSettingsを型付きのFlutterRouteにデコードし、switch式で
網羅的かつ型安全にルートを解決します。間に文字列やハンドラークラスのボイラープレートは挟まりません。
仕組み:
- ジェネレーターは
sealed class FlutterRoute(ページ用)と、必要に応じてsealed class FlutterDialogRoute(ダイアログ用)を生成します。各@InlayFlutterRoute/@InlayFlutterDialogは、それぞれのsealed型の サブクラスになります。 - 生成された
decodeInlayRouteData関数が、PageSettingsをどちらかの型にデコードします (戻り値はInlayRoute?)。 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.ではなく通常のMaterialAppを使います。
内部の仕組み
- ネイティブの初期化 -
start()/init()がFlutterEngineGroupを作成し、必要に応じて エンジンを1つプリウォームします。 - Push - フレームワークは
PageSettingsをURL文字列にエンコードし、グループから新しいエンジンを作成して、 ネイティブコンテナ(Activity / ViewController)内に表示します。 - ダイアログの表示 - ダイアログルートの場合、ネイティブ側は不透明なコンテナではなく透明なコンテナ
(Androidでは
DialogFragment、iOSでは.のモーダル)を作成します。エンジンは同じ方法で 起動しますが、Flutterは下に見えているネイティブ画面の上に描画します。overFullScreen - Dartのエントリーポイント - すべてのエンジンは同じエントリーポイント(デフォルトでは
inlayMain)を 実行します。ルーティングライブラリが初期パスを読み取るか、命令的な方式ではPageSettingsをsealedな ルートにデコードしてパターンマッチングします。 - Pop - ネイティブコンテナを閉じると、エンジンが破棄され、プラットフォームチャネルの登録が
クリーンアップされます。ダイアログの場合は、ダイアログ/シートが閉じられたときに
InlayNavigator.が自動的に呼び出されます。instance. pop()
ルートは次の形式でシリアライズされます:
/path/with/:params?extraParam=valueDart側はこれをInlayNavigator.でデコードします。