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 InlayNavigator opens 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 as InlayNavigator.instance (Dart), InlayNavigator.shared (iOS), or the InlayNavigator object (Android).
  • PageSettings - A data class carrying a routeId, optional params, optional path, and the schemaFingerprint used 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 extends FlutterRouteBase) 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 extends FlutterDialogRouteBase) - separate from FlutterRoute so you can distinguish pages from dialogs in pattern matching.
  • A Swift struct conforming to FlutterDialogRoute / Kotlin data class implementing FlutterDialogRoute.

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 in InlayNavigator.instance.push().
  • A typed handler method in the generated NativeRouteHandler base class (Swift and Kotlin), so you implement native navigation with full type safety.

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.push(...) / InlayNavigator.createFragment(...) 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.

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:

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;
}

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:

  1. Declarative - Works with MaterialApp.router 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 use InlayDialogPage / InlayBottomSheetPage.
  2. Imperative - Works with plain MaterialApp and no routing library. The generated sealed class FlutterRoute / sealed class FlutterDialogRoute hierarchies let you use Dart pattern matching for exhaustive, type-safe route resolution. Dialog routes use runInlayDialog.

Declarative integration points

The framework encodes the route as a URL path (e.g. /contact-details/abc-123) and passes it as the initial route for the new engine. There are two integration points:

  • InlayNavigator.initialPath - reads the platform's defaultRouteName and 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.fetchInitialRoute(decoder) - 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. Use decodeFlutterRouteData if you only have page routes, or decodeInlayRouteData if 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 via MaterialApp.router's 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.instance.pop() when dismissed, closing the native transparent container. The MaterialApp theme must set scaffoldBackgroundColor: Colors.transparent 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:

  1. The generator produces a sealed class FlutterRoute (for pages) and optionally a sealed class FlutterDialogRoute (for dialogs). Each @InlayFlutterRoute / @InlayFlutterDialog becomes a subclass of the respective sealed type.
  2. The generated decodeInlayRouteData function decodes PageSettings into either type (returning InlayRoute?).
  3. Use InlayNavigator.fetchInitialRoute(decodeInlayRouteData) 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.
@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.router) because there's no declarative router involved.

How it works under the hood

  1. Native init - start() / init() creates a FlutterEngineGroup and optionally prewarms one engine.
  2. Push - the framework encodes PageSettings into a URL string, creates a new engine from the group, and presents it in a native container (Activity / ViewController).
  3. Present dialog - for dialog routes, the native side creates a transparent container (DialogFragment on Android, .overFullScreen modal on iOS) instead of an opaque one. The engine starts the same way, but Flutter renders over the visible native screen underneath.
  4. Dart entrypoint - every engine runs the same entrypoint (inlayMain by default). A routing library reads the initial path, or the imperative approach decodes PageSettings into a sealed route for pattern matching.
  5. Pop - dismissing the native container destroys the engine and cleans up platform channel registrations. For dialogs, InlayNavigator.instance.pop() is called automatically when the dialog/sheet is dismissed.

Routes are serialized as:

/path/with/:params?extraParam=value

The Dart side decodes this with InlayNavigator.initialPath.

On this page