Navigation
Type-safe navigation between native and Flutter screens in both directions.
Inlay provides type-safe navigation between native (iOS/Android) and Flutter screens, in both directions. Routes are defined once as annotated Dart classes, and the generator produces matching types for Dart, Swift, and Kotlin, so every navigation call is checked at compile time - there are no arbitrary strings crossing the boundary.
The framework uses a multi-engine approach: when native code navigates to a Flutter screen,
a new native container (an Activity on Android, a ViewController on iOS) is created with its
own Flutter engine. The same happens whenever a Flutter route is pushed through
InlayNavigator - from native code, or from Flutter when you explicitly open a new container
(e.g. pushForResult()). Pushes handled by the Flutter-side router stay within the current
container and share its engine and navigation stack. All engines share resources via a
FlutterEngineGroup, so starting a new one is cheap, and you never manage engines directly.
New to Flutter engines and the multi-engine model? See Core concepts for the underlying theory. This page focuses on the navigation API.
Concepts
- Flutter route - A screen rendered by Flutter. Navigating to it from native code creates a
new engine inside a native container (Activity / ViewController). From within Flutter, pushing
it through the router uses the existing engine's navigation stack; pushing it through
InlayNavigatoropens a new container and engine. - Flutter dialog route - A dialog, bottom sheet, or action sheet rendered by Flutter over a native screen. The native side opens a transparent container so the underlying screen stays visible; Flutter renders the overlay content (barrier, animation, positioning). Uses the same engine-per-container model as regular routes.
- Native route - A screen rendered by the native platform. Flutter can request navigation to it, and the native side decides how to present it.
InlayNavigator- The singleton that orchestrates all cross-boundary navigation. Accessed asInlayNavigator.(Dart),instance InlayNavigator.(iOS), or theshared InlayNavigatorobject (Android).PageSettings- A data class carrying arouteId, optionalparams, optionalpath, and theschemaFingerprintused for drift detection across the platform boundary. Generated route classes create this for you - you rarely touch it directly.
Defining routes
Routes are plain Dart classes with annotations. Running the code generator produces type-safe route classes for Dart, Swift, and Kotlin, so every platform gets compile-time safety. Let's go through the three kinds of routes.
Flutter routes
Annotate with @InlayFlutterRoute and provide a path template. The path determines the URL
passed to the Flutter routing library (go_router, auto_route, etc.) when a new engine starts -
this is how the framework tells the router which screen to display. Path parameters (:param)
map to fields of the same name on the class. Keep those fields required - the generator does not
enforce it, and a null path parameter would encode as an empty path segment.
@InlayFlutterRoute('/contact-details/:contactId')
class ContactDetailsPage {
const ContactDetailsPage({
required this.contactId,
this.badges,
});
final String contactId;
final List<ContactBadge>? badges;
}The generator produces:
- A Dart class extending the generated
sealed class FlutterRoute(which itself extendsFlutterRouteBase) with serialization built in, enabling exhaustive pattern matching. - A Swift struct / Kotlin data class with the same fields, usable directly in native code.
Flutter dialog routes
Annotate with @InlayFlutterDialog for overlays (dialogs, bottom sheets, action sheets) that
are rendered by Flutter but presented over a native screen. The native side opens a
transparent container so the underlying screen stays visible, and Flutter renders the dialog
content.
@InlayFlutterDialog('/confirm-delete/:itemId')
class ConfirmDeleteDialog {
const ConfirmDeleteDialog({required this.itemId, this.title});
final String itemId;
final String? title;
}The generator produces:
- A Dart class extending the generated
sealed class FlutterDialogRoute(which extendsFlutterDialogRouteBase) - separate fromFlutterRouteso you can distinguish pages from dialogs in pattern matching. - A Swift struct conforming to
FlutterDialogRoute/ Kotlin data class implementingFlutterDialogRoute.
Dialog routes work identically to page routes in the schema - they have path templates, support path and query parameters, and get the same serialization. The only difference is how the native side presents them (a transparent container instead of an opaque one).
Native routes
Annotate with @InlayNativeRoute for screens that live on the native side.
@InlayNativeRoute()
class NativeEditProfilePage {
const NativeEditProfilePage({required this.contactId});
final String contactId;
}The generator produces:
- A Dart class with a
toNativeRoute()method for use inInlayNavigator..instance. push() - A typed handler method in the generated
NativeRouteHandlerbase class (Swift and Kotlin), so you implement native navigation with full type safety.
Navigating
All navigation goes through InlayNavigator. Pick your platform:
// 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 helpers live in the optional inlay_compose plugin. See
Getting started for adding it. Projects that don't
use Compose depend only on inlay and use InlayNavigator. /
InlayNavigator. directly.
Host Activity forwarding (Android)
InlayFlutterFragment extends Flutter's FlutterFragment, which requires the host Activity to
forward seven callbacks - without them, deep links, back handling, user-leave events, and memory
trimming don't reach Flutter. This applies to any Activity hosting an InlayFlutterFragment
directly, via InlayFlutterScreen (from inlay_compose), or via InlayFlutterDialogFragment.
The simplest option is to extend InlayFlutterHostActivity, which wires the forwarding for you.
If you need a different base class (for example AppCompatActivity), use
InlayFragmentHostDelegate from your own Activity overrides.
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)
}
}The delegate walks the whole Fragment tree, so nested InlayFlutterFragments (including the one
inside InlayFlutterDialogFragment) are covered automatically.
Dialogs
Dialog routes are presented in a transparent native container so the underlying screen stays
visible. Flutter renders the dialog content, barrier, and animations. From Flutter, presenting a
dialog is a plain push() - the framework detects the flutterDialog route type and uses
presentDialog instead of push on the native side.
// Present a dialog over the current native screen
await InlayNavigator.instance.push(
ConfirmDeleteDialog(itemId: '42'),
);Handling native routes (Flutter → Native)
When Flutter pushes a native route, the framework dispatches it to a NativeRouteHandler you
register on the native side. The generated handler has a typed method for each
@InlayNativeRoute, so you don't parse strings.
// 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)
}
}Returning a result from a screen
A screen opened for a decision (a confirm dialog, a picker, a native form) often needs to hand
a value back to whoever opened it. Declare the result type once on the route with result: and
the generator produces the typed plumbing on both sides:
@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;
}The result type is any type the generator supports - a primitive, an enum, or an annotated
data class - and it becomes part of the schema fingerprint, so a
host and module that disagree about it are caught like any other drift. On the native side,
result-typed routes are generated as FlutterRouteWithResult / FlutterDialogRouteWithResult
(Swift protocols, generic Kotlin interfaces) instead of the plain FlutterRoute /
FlutterDialogRoute. Both extend the plain ones, and the result type parameter is what gives
the onResult callbacks below their static type. A screen that returns
without an explicit result (back gesture, barrier tap, system back) delivers null to the
caller exactly once.
Native → Flutter: awaiting a Flutter screen's result
The native push / presentDialog gain an onResult callback. The callback is typed - the
framework decodes the value internally, so it receives the declared result type directly
(Bool? / Boolean? here), or nil / null when the screen is dismissed without a result:
InlayNavigator.shared.presentDialog(
from: self,
route: ConfirmActionDialog(action: "delete"),
onResult: { confirmed in
// confirmed: Bool?
if confirmed == true { /* ... */ }
}
)On the Flutter side, the screen returns its value. For a dialog/bottom-sheet route, pass the
generated encodeResult to the page and pop with the value; for a full screen, call the
generated 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 → native (or Flutter → Flutter): awaiting the result
The generated route class gains pushForResult(), returning the typed value:
// 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();On the native side, the generated NativeRouteHandler method for a result-typed route gains a
completion you invoke with the typed value:
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)
}The callback lives in process memory. If Android kills the process while the opened screen is visible, the callback is not restored, so don't rely on the result always being delivered. Keep anything that must survive process death in a store.
Flutter router integration
When the native side opens a Flutter screen, the framework encodes the route and passes it to the new Flutter engine. The Dart side resolves that into the correct widget. Two integration styles are supported:
- Declarative - Works with
MaterialApp.and any Navigator 2.0 routing library (go_router, auto_route, etc.). The framework passes the initial route as a URL path and the router matches it to a screen. Dialog routes userouter InlayDialogPage/InlayBottomSheetPage. - Imperative - Works with plain
MaterialAppand no routing library. The generatedsealed class FlutterRoute/sealed class FlutterDialogRoutehierarchies let you use Dart pattern matching for exhaustive, type-safe route resolution. Dialog routes userunInlayDialog.
Declarative integration points
The framework encodes the route as a URL path (e.g. /) and passes it as
the initial route for the new engine. There are two integration points:
InlayNavigator.- reads the platform'sinitialPath defaultRouteNameand returns the URL path. Pass this to your routing library as the initial location. It is synchronous and carries path parameters plus simple fields (primitives, enums) as query parameters - complex fields are not in the path.InlayNavigator.- fetches the full route data (including complex fields like lists and nested objects) from the native host and decodes it into a typed route object. UsefetchInitialRoute(decoder) decodeFlutterRouteDataif you only have page routes, ordecodeInlayRouteDataif you have both pages and dialogs.
If your routes only use simple parameters, initialPath alone is sufficient. If any route has
complex fields (e.g. List<ContactBadge>? badges), you need both.
Two wrappers coordinate the navigation stacks:
InlayBackButtonDispatcher- when the user presses back and the router stack is empty, dismisses the native container (Activity/ViewController) instead of doing nothing.InlayNativePopGestureObserver- syncs the iOS interactive back-swipe gesture with the Flutter navigation stack. Without it, the native swipe-back may dismiss the entire Flutter container even when there are in-Flutter routes to pop. Wrap it around the router's child viaMaterialApp.'srouter builder.
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(),
),
),
);
}For dialog routes in go_router, use pageBuilder (not builder) so you can return
InlayDialogPage (an AlertDialog-style overlay; barrierDismissible/barrierColor
configurable) or InlayBottomSheetPage (a modal sheet; isScrollControlled, isDismissible,
enableDrag, showDragHandle configurable). Both automatically call
InlayNavigator. when dismissed, closing the native transparent container. The
MaterialApp theme must set scaffoldBackgroundColor: Colors. for dialog engines so
the native screen shows through.
Imperative (sealed class + pattern matching)
If you don't use a declarative router, decode PageSettings into a typed FlutterRoute and use a
switch expression for exhaustive, type-safe route resolution, with no strings or handler-class
boilerplate in between.
How it works:
- The generator produces a
sealed class FlutterRoute(for pages) and optionally asealed class FlutterDialogRoute(for dialogs). Each@InlayFlutterRoute/@InlayFlutterDialogbecomes a subclass of the respective sealed type. - The generated
decodeInlayRouteDatafunction decodesPageSettingsinto either type (returningInlayRoute?). - Use
InlayNavigator.to get the typed route, then pattern-match. The compiler enforces exhaustiveness - add a new route and you get a compile error until you handle it.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 sets up a transparent MaterialApp and calls your onReady callback after the
first frame. When the dialog/sheet is dismissed, the native transparent container is automatically
closed. You use standard Flutter APIs (showDialog, showModalBottomSheet, etc.) inside the
callback - the framework doesn't impose any special dialog widget.
The sealed class hierarchies do most of the work here. Pattern matching is exhaustive - adding
a new @InlayFlutterRoute / @InlayFlutterDialog makes the switch non-exhaustive, so you get
a compile error until you handle the new case. You can destructure route fields directly in the
pattern (e.g. ConfirmDeleteDialog(:final itemId)), and there is no handler class to extend or
interface to implement - the whole integration is a single switch expression. Unknown routes
are covered too: decodeInlayRouteData returns null for unrecognized route IDs (including the
prewarm engine's placeholder), and you handle that in the null branch.
This approach uses a plain MaterialApp (not MaterialApp.) because there's no
declarative router involved.
How it works under the hood
- Native init -
start()/init()creates aFlutterEngineGroupand optionally prewarms one engine. - Push - the framework encodes
PageSettingsinto a URL string, creates a new engine from the group, and presents it in a native container (Activity / ViewController). - Present dialog - for dialog routes, the native side creates a transparent container
(
DialogFragmenton Android,.modal on iOS) instead of an opaque one. The engine starts the same way, but Flutter renders over the visible native screen underneath.overFullScreen - Dart entrypoint - every engine runs the same entrypoint (
inlayMainby default). A routing library reads the initial path, or the imperative approach decodesPageSettingsinto a sealed route for pattern matching. - Pop - dismissing the native container destroys the engine and cleans up platform channel
registrations. For dialogs,
InlayNavigator.is called automatically when the dialog/sheet is dismissed.instance. pop()
Routes are serialized as:
/path/with/:params?extraParam=valueThe Dart side decodes this with InlayNavigator..