adaptive_nav

A Navigator 2.0 router that renders one navigation state two ways — a stack on a phone, master-detail panes on a tablet or desktop — and keeps screen state across the switch.

Rotate the device, drag the window edge, unfold a foldable: the layout changes, the screens do not. Scroll offsets, half-typed text fields, controllers and animations all survive, because nothing is remounted — the branch Navigators move between the layouts under stable GlobalKeys.

The package is generic over your route type R and knows nothing about your screens. You supply the branches, the R ↔ URL codec and the guards.

Status. The package runs in production in the author's own apps, so it is under active development. Until 1.0 a minor version may break the API; every such change is listed under ### Breaking in the CHANGELOG.

Ready for iPhone Duo. On the outer display the rail moves into the system's own column, under the camera, and follows it as the phone turns; the inner display gets two panes, with a rail in landscape and a bottom bar in portrait. The screens keep their state through every fold. Nothing to configure — see iPhone Duo.

iPhone Duo folded and unfolded, turned both ways: the rail follows the system bar, the panes split on the inner display, and the open screen survives every change

Try it in the browser: the example app in a desktop window you can resize, on a phone and a tablet you can rotate, and on iPhone Duo and Galaxy Z Fold8 you can fold.

Install

dependencies:
  adaptive_nav: ^0.14.0

Quick start

Define a route type and a codec, describe your branches, and hand an AdaptiveNav to MaterialApp.router. The route type needs value equality (== and hashCode): NavEntry equality and the URL parser compare routes with it.

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

@immutable
sealed class AppRoute {
  const AppRoute();
}

class PeopleList extends AppRoute {
  const PeopleList();

  @override
  bool operator ==(Object other) => other is PeopleList;

  @override
  int get hashCode => (PeopleList).hashCode;
}

class PersonDetail extends AppRoute {
  const PersonDetail(this.id);

  final int id;

  @override
  bool operator ==(Object other) => other is PersonDetail && other.id == id;

  @override
  int get hashCode => Object.hash(PersonDetail, id);
}

class SettingsRoute extends AppRoute {
  const SettingsRoute();

  @override
  bool operator ==(Object other) => other is SettingsRoute;

  @override
  int get hashCode => (SettingsRoute).hashCode;
}

class AppCodec extends RouteCodec<AppRoute> {
  const AppCodec();

  @override
  Uri encode(AppRoute route) => switch (route) {
    PeopleList() => Uri.parse('/people'),
    PersonDetail(:final int id) => Uri.parse('/people/$id'),
    SettingsRoute() => Uri.parse('/settings'),
  };

  @override
  AppRoute decode(Uri uri) {
    final List<String> seg = uri.pathSegments;
    if (seg.isNotEmpty && seg.first == 'settings') {
      return const SettingsRoute();
    }
    final int? id = seg.length >= 2 ? int.tryParse(seg[1]) : null;
    return id != null ? PersonDetail(id) : const PeopleList();
  }

  // Which tab a route belongs to.
  @override
  int branchOf(AppRoute route) => switch (route) {
    PeopleList() || PersonDetail() => 0,
    SettingsRoute() => 1,
  };
}

// Your screens, one per route.
Widget buildPage(AppRoute route) => switch (route) {
  PeopleList() => const Text('People'),
  PersonDetail(:final int id) => Text('Person $id'),
  SettingsRoute() => const Text('Settings'),
};

final shellConfig = AdaptiveShellConfig<AppRoute>(
  branches: <BranchConfig<AppRoute>>[
    BranchConfig<AppRoute>(
      id: 'people',
      icon: Icons.people_outline,
      label: 'People',
      // Present: this branch splits into master + detail when there is room.
      masterDetail: const MasterDetailConfig(paneRatio: 0.36),
      pageBuilder: buildPage,
    ),
    BranchConfig<AppRoute>(
      id: 'settings',
      icon: Icons.settings_outlined,
      label: 'Settings',
      pageBuilder: buildPage, // no masterDetail: a plain tab
    ),
  ],
);

// The starting state: one stack per branch, each holding its root.
final initialState = NavState<AppRoute>(
  activeBranch: 0,
  branches: <BranchStack<AppRoute>>[
    BranchStack<AppRoute>(<NavEntry<AppRoute>>[
      NavEntry<AppRoute>(
        route: const PeopleList(),
        transition: AppTransition.platform,
      ),
    ]),
    BranchStack<AppRoute>(<NavEntry<AppRoute>>[
      NavEntry<AppRoute>(
        route: const SettingsRoute(),
        transition: AppTransition.platform,
      ),
    ]),
  ],
);

class App extends StatefulWidget {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  final AdaptiveNav<AppRoute> nav = AdaptiveNav<AppRoute>(
    shellConfig: shellConfig,
    initialState: initialState,
    codec: const AppCodec(), // optional: URLs and deep links
  );

  @override
  void dispose() {
    nav.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => MaterialApp.router(routerConfig: nav);
}

void main() => runApp(const App());

AdaptiveNav is a RouterConfig: the delegate, the URL parser and provider and the back button dispatcher, plus the navigation API. It creates and disposes all of them itself. AdaptiveRouterDelegate and AdaptiveRouteInformationParser are still public for an app that wires the Router by hand.

A complete, runnable app is in example/.

The layouts

Chrome and pane count are decided separately, so they combine rather than step through fixed states:

Window width Chrome Panes
< 600 (compact) NavigationBar master in the body, detail as an overlay over the bar
≥ 600, MasterDetailConfig.fits is false NavigationRail one pane — the detail covers the master
≥ 600, MasterDetailConfig.fits is true NavigationRail two panes — master | detail

The width is measured without the side insets. With the defaults — a rail of 80 plus its 1-point divider, minimums of 320 and 360, no pane margins, gap or side insets — fits turns true from 761 across.

A bar with two panes above it is the fourth combination, and it exists for one device: iPhone Duo's inner display in portrait is 669 points across and Apple asks for horizontal bars there. See iPhone Duo.

Where the chrome goes is AdaptiveShellConfig.chromeLayout, which returns a ChromePlacement — bottom, left or right. The pane count is decided by MasterDetailConfig.fits — whether the master and the detail both fit at their minimum widths, with the decoration's margins, the gap and any safe-area inset the panes only bleed under already subtracted. A wide window with a narrow content area therefore stays single-pane, which is the right answer.

Below 600 across (kCompactWidthBreakpoint, without the side insets) a branch is always one pane, whatever the minimums. Otherwise a narrowing window would drop to one pane beside the rail and jump back to two as soon as the bar replaced it and gave its width back.

PaneMetrics is that arithmetic, exported so an app that decides "is this the wide layout" for itself arrives at the same number:

final Size window = MediaQuery.sizeOf(context);
final EdgeInsets padding = MediaQuery.paddingOf(context);
final double panes = PaneMetrics.paneAreaWidth(
  window: window.width,
  padding: padding,
  placement: shellConfig.chromeLayout(window, padding),
  railWidth: shellConfig.railWidth,
  panes: shellConfig.panes,
  rail: shellConfig.rail,
);

While the detail is empty, collapseWhenDetailEmpty (default true) collapses the split and gives the master the full width — no placeholder column next to an empty pane. Set it to false and the empty detail shows BranchConfig.detailPlaceholder instead. Either way both of the branch's navigators stay mounted, so no state is lost when the split appears or goes.

The API

Navigation is imperative and lives on AdaptiveNav (and on the delegate it forwards to):

nav.push(route, transition: ..., onExit: ...); // returns a result future
nav.pop();
nav.popWithResult(value);
nav.replace(route);
nav.resetTo(route);        // branch becomes [route]
nav.resetDetailTo(route);  // branch becomes [root, route]
nav.popToRoot();
nav.goBranch(index, resetToRoot: false);
nav.reevaluate();          // re-run the redirect hook
nav.state;                 // the current NavState

To react to every change, listen to nav.routerDelegate.

Holding it in a provider

AdaptiveNav is not a Listenable, so a dependency container that rejects change notifiers — riverpod_lint's unsupported_provider_value, for one — takes it as is. Nothing else needs a provider of its own: app state that drives the router stays an ordinary notifier and is pushed in.

@Riverpod(keepAlive: true)
AdaptiveNav<AppRoute> nav(Ref ref) {
  final nav = AdaptiveNav<AppRoute>(
    shellConfig: shellConfig,
    initialState: initialState,
    codec: const AppCodec(),
    immersive: ref.read(fullscreenProvider),
    resizablePanes: true,
    onPaneCommit: (branchId, fraction) => saveFraction(branchId, fraction),
  );
  ref.listen(fullscreenProvider, (_, bool on) => nav.immersive = on);
  ref.listen(sessionProvider, (_, _) => nav.reevaluate());
  ref.onDispose(nav.dispose);
  return nav;
}

transition takes an AppTransition: platform, fade, modal, none, or adaptive (see below). Left out, it falls back to BranchConfig.transition.

resetDetailTo is the lateral move of a master-detail list: picking another item replaces a detail of any depth — a card with an editor on top of it — in a single state mutation, so the pane never flickers through an empty frame.

Exit guards

An entry can carry an onExit guard, and every way out goes through it: back, gesture, app bar, replace, resetTo, popToRoot, resetDetailTo, and switching tabs.

nav.push(
  PersonEdit(id),
  onExit: () => confirmDiscard(context), // Future<bool>
);

A tab switch is gated too. The screen is only parked, not destroyed, but the user is asked about unsaved work all the same — and a refusal leaves the tab where it was. Screens without a guard never prompt, and such a switch stays synchronous, completing in the same frame.

System back

The shell tells Android in one answer whether back belongs to the app, so predictive back reaches the app whenever there is something to go back from: a dialog, the shell drawer, a pushed screen, a detail pane, a PopScope that blocks pop.

A drawer or a Scaffold.showBottomSheet inside a screen is not reported — the same as in a plain MaterialApp — so at a branch root back closes the app instead. Block pop while the drawer is open and close it yourself:

class _InboxState extends State<Inbox> {
  final GlobalKey<ScaffoldState> _scaffold = GlobalKey<ScaffoldState>();
  bool _drawerOpen = false;

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: !_drawerOpen,
      onPopInvokedWithResult: (bool didPop, Object? _) {
        if (!didPop) _scaffold.currentState?.closeDrawer();
      },
      child: Scaffold(
        key: _scaffold,
        drawer: const Drawer(child: Filters()),
        onDrawerChanged: (bool open) => setState(() => _drawerOpen = open),
        body: const InboxList(),
      ),
    );
  }
}

At a branch root back goes to the system. With AdaptiveShellConfig.backToBranch: 0 back at the root of any other branch switches to the start tab instead. The root's onExit is asked as on a tab tap. The switch has no predictive back preview.

AdaptiveRouteInformationParser turns an incoming URL into a NavState with your RouteCodec, and reports the current location back for the address bar. The round trip is defined on the visible location — the top of the active branch. Like go_router, a deep link opens the target screen rather than synthesising a multi-level back stack for it.

A link changes only its own branch and keeps what it can there: the branch's current root stays, and a screen already open in the stack is reused, with the stack cut back to it. Screens cut off this way are not asked through their onExit; their push futures complete with null.

AdaptiveShellConfig.redirect is the auth guard: it can substitute the state before it is applied. It runs once at start (on the first location with a codec, on the first frame without one), on incoming URLs and on AdaptiveNav.reevaluate(), which is the equivalent of refreshListenable — call it when the session changes.

Chrome

  • barBuilder / railBuilder replace the Material default completely — your own pill, your own icons, your own destinations. They receive branch indices, so nothing is mapped twice, and may return null to hide the chrome for a frame. A custom rail has to be exactly AdaptiveShellConfig.railWidth wide: the layout is computed from that number, and a rail that disagrees with it throws the detail pane width off by the difference.
  • drawerBuilder adds a Scaffold.drawer, with scaffoldKey to open it from anywhere. It receives whether the layout currently uses the rail.
  • showsChrome hides the bar and rail for a given state — an auth area with no tabs. Only the chrome widget goes; the branch navigators stay mounted.
  • BranchConfig.showsInChrome: false keeps a branch navigable but gives it no destination (a profile or auth branch, entered programmatically).
  • extendBodyBehindBar extends the body under a floating bar. It is off by default, because under the opaque default NavigationBar the content would simply disappear.
  • branchFadeDuration turns tab switches into a Material "fade through" instead of an instant swap.

Snack bars

Each pane has its own ScaffoldMessenger, so a message raised from a screen appears once, in the pane the action came from, rather than twice on a phone or stretched under both panes on a tablet. Calling ScaffoldMessenger.of(context) from any screen inside a pane already lands on the right one.

For something that belongs to no pane in particular, AdaptiveNav exposes both:

nav.masterMessenger?.showSnackBar(const SnackBar(content: Text('Synced')));
nav.detailMessenger?.showSnackBar(...);

Panes

PaneDecoration makes the panes floating rounded cards on a canvas, and RailDecoration does the same for the rail:

panes: PaneDecoration(
  margin: const EdgeInsets.all(12),
  gap: 8,
  radius: BorderRadius.circular(16),
  canvasColor: (context) => Theme.of(context).colorScheme.surfaceContainerHighest,
  splitHandleColor: (context) => Theme.of(context).colorScheme.outlineVariant,
),

The margins and the gap are data, not decoration painted on top: they reduce the width left to the panes and therefore move the "one pane or two" threshold. If your screens compute the layout with a formula of their own, they have to subtract the same values.

The boundary sits at MasterDetailConfig.paneRatio of the pane area. alignToWindowCenter: true puts it on the window's horizontal centre instead, so a rail on the left comes out of the master's half. portraitPaneRatio replaces both in a portrait window — a narrow list there, the centre in landscape. The user's drag and an active fold (Half open) still win, and so do the pane minimums.

resizablePanes: true makes the boundary draggable, per branch, clamped by both panes' minimum widths. The chosen width goes out on release, so it can be persisted:

final nav = AdaptiveNav<AppRoute>(
  // ...
  resizablePanes: true,
  paneFractions: loadedFractions, // if you already have them
  onPaneCommit: (branchId, fraction) => save(branchId, fraction),
);

// Or seed it later, once an async read comes back. Branches the user has
// already dragged in this session are left alone.
nav.paneSplit!.restore(await loadFractions());

nav.immersive = true slides the rail out of the wide layout and hands the strip to the panes; pass immersive: false to the constructor to enable the mode. It changes geometry only: the layout class stays frozen, so a branch never crosses the two-pane threshold mid-animation.

With a hand-built Router, the same two live in the config as AdaptiveShellConfig.paneSplit (a PaneSplitController the app owns) and AdaptiveShellConfig.immersive (a ValueListenable<bool>).

What screens can read

Inherited scopes let your screens adapt without recomputing the layout:

  • DetailEntryScope.isDetailRoot(context) — this screen is the branch's first detail. In the wide layout it fills the pane, so its app bar should offer a close button (clearing the selection) rather than a back arrow. Navigator.canPop() cannot answer that: a transparent base page always sits underneath.
  • DetailPaneScope.of(context) — the detail is in its own pane; DetailPaneScope.isFullScreen(context) — it is a detail shown across the whole area. On compact a detail covers the navigation bar, so its bottom edge becomes its own responsibility.
  • StatusRowScope.trailingReserveOf(context) and StatusColumnScope.of(context) — for the experimental iPhone Duo options, see Inner display.

AppTransition.adaptive uses the same signal: a detail fades when it swaps the contents of a pane and gets the platform slide when it takes the whole screen — and it decides that while the animation runs, so the dismissal matches the layout the user is actually looking at.

Notes

The keyboard is the focused pane's business. Both shell Scaffolds run with resizeToAvoidBottomInset: false, so a screen inside a pane needs its own Scaffold or its own handling of viewInsets.bottom.

The layout is decided at build time, from MediaQuery, never inside a LayoutBuilder.

Branches are mounted lazily, on first entry, and live from then on.

The reasoning behind these, and behind most of the design, is in doc/design.md.

iPhone Duo

A fold is a window resize, so folding and unfolding cost nothing here: the screens survive it the same way they survive a rotation. What makes iPhone Duo different is where the system puts its own bars. The status bar is a vertical strip 84 points wide along one edge, which edge it is changes as the device is folded and turned, and Apple asks that an app's bars follow it — Designing for iPhone Duo. The one exception is the inner display in portrait, which has the vertical room for ordinary horizontal bars.

The package does that out of the box. It reads the posture straight off MediaQuery.padding — a substantial inset on exactly one side is the system bar — so there is nothing to configure and no platform channel. The sizes below were measured on the iPhone Duo simulator under Xcode 27.1:

Posture Window Safe area Chrome Pane area
outer, portrait 466 × 678 right 84 rail, right, under the camera 377
outer, landscape 678 × 466 left 84 rail, left, under the camera 589
outer, landscape, turned the other way 678 × 466 right 84 rail, right, camera below it 589
inner, portrait 669 × 951 top 82 bar, bottom 669
inner, landscape 951 × 669 right 84 rail, left 786

Outer display: the rail in the camera's column

The rail joins the system's column rather than taking one of its own: the camera is at one end of that column and the controls are meant to line up with it. To sit with the camera and the clock the rail needs numbers no API reports — how far along the column the system's glyphs reach, and which vertical line they are centred on. They are in AdaptiveShellConfig.systemBar, and SystemBarMetrics.measured holds what the simulator shows: 150 points upright, 80 in landscape, where the system hides the clock and only the camera is left, and an axis 48 points in from the window edge. They were read off screenshots, so they are numbers to adjust rather than to trust; SystemBarMetrics.none ignores the bar entirely.

The camera is in a corner of the glass, so turning the phone moves it along the column: at the top upright and with the column on the left, at the bottom with the column on the right. The reserve follows it. SystemBarMetrics.end takes the window and the column's side and returns the end, should other hardware put the camera elsewhere.

That column is short — seven one-line destinations on the outer display upright (six whose labels wrap), five turned, against a desktop's dozen — and Material's NavigationRail neither scrolls nor wraps: past its height it simply overflows. So the shell counts them instead and puts the rest behind a menu button at the end of the rail (AdaptiveShellConfig.railOverflow, on by default). The order never changes as you navigate; an active destination among the hidden ones leaves the rail unselected and lights the button instead.

The count is arithmetic, not measurement — a LayoutBuilder around the rail is the one thing doc/design.md warns against — so the shell lays the labels out with a TextPainter to know how tall each destination is. A label too wide for the rail takes a second line and that destination grows with it, alone: "Home" is 64 points beside "People"'s 80, and the shell adds the heights up one by one rather than counting them all at the tallest.

The button itself is AdaptiveShellConfig.railOverflowBuilder's to draw — it gets the hidden branch indices, the callback to switch to one, and whether the active branch is among them. Which destinations it carries and the 56 points it sits in stay with the shell, because the count above depends on both; a button that needs more room than that is a railBuilder rail.

Inner display

In landscape there is room for the rail and both panes to keep the arrangement they have everywhere else, so the rail leads and the pane against the system bar bleeds under it. Nothing is inset at the top there, so the rail keeps cornerClearance (16 points) free rather than start in the rounded corner.

In portrait the bars are horizontal and the display is 669 points across — enough for two panes above a bottom bar, the one layout that exists for this device alone. The pane cards run up under the status bar and the top inset is reserved once for the whole layout, so its 82 points cost the panes nothing but the card's margin.

Experimental: headers in the status row

In portrait the top inset spans the whole width, but the clock takes only the top right corner. AdaptiveShellConfig.liftHeadersIntoStatusRow (off by default, iOS only) lifts the panes' headers beside it, as Safari does: each screen gets a top inset of 20 instead of 82, and the pane under the glyphs learns their width from StatusRowScope.trailingReserveOf(context) — zero everywhere else. The screen ends its AppBar actions with a box that wide, and moves the title to the start where it would drift towards the clock:

final double reserve = StatusRowScope.trailingReserveOf(context);
AppBar(
  title: const Text('Trash'),
  // paneWidth: the screen's width, e.g. from a LayoutBuilder.
  centerTitle: reserve == 0 ? null : paneWidth - 2 * reserve >= 240,
  actions: <Widget>[
    TextButton(onPressed: empty, child: const Text('Empty')),
    SizedBox(width: reserve),
  ],
)

A header that is not a 56-point toolbar, or has no trailing box, ends up under the clock. The geometry is SystemBarMetrics.statusRowAxis (48) and statusRowTrailing (120), measured on the simulator.

Experimental: actions in the status column

In landscape the glyphs are a column on the right again, empty below them. AdaptiveShellConfig.actionsInStatusColumn (off by default, iOS only) hands that space to the pane against it, and StatusColumnActions moves a screen's header actions there and back as the column comes and goes. The buttons stand in the body, so the screen's drawer and sheets cover them:

StatusColumnActions(
  actions: <Widget>[
    TextButton(onPressed: empty, child: const Text('Empty')),
  ],
  builder: (BuildContext context, List<Widget> actions) => Scaffold(
    appBar: AppBar(title: const Text('Trash'), actions: actions),
    // The side insets keep the list clear of the column.
    body: StatusColumnActions.body(
      SafeArea(top: false, bottom: false, child: list),
    ),
  ),
)

The body is assumed to start under a standard AppBar; a debug build reports one that does not. The column itself is StatusColumnScope.of(context), and its geometry SystemBarMetrics.statusColumnTop (106) and statusColumnWidth (72). iOS apps do not put actions on the trailing edge — Apple's own leave the column empty — so this is off by default too.

What it means for an app

  • Nothing changes on any other device. All of the above applies only where one side of the window has a vertical bar of 60 points or more and the other does not. A notched iPhone in landscape has large horizontal insets, but symmetric ones; a display cutout or Android's navigation buttons are one-sided but far narrower; the rail moves its destinations past them. An iPhone in landscape reports the Dynamic Island on both sides, so its rail stays against the edge. chrome_layout_test.dart and duo_poses_test.dart pin every phone, tablet and desktop case to the layout the package always gave it.
  • The inner display in portrait needs smaller minimums. It is the roomiest posture — no rail takes a share, so all 669 points go to the panes — but the defaults ask for 680. Pick masterMinWidth and detailMinWidth that fit, the way example/ does with 270 and 270, or accept a single stack there. The outer display is under 600 across, so it stays a stack whatever the minimums, as Apple asks.
  • Build against the iOS 27.1 SDK. Older SDKs put the app in a compatibility box (375 × 667) on both displays, so none of the above applies and nothing can be tested.

What the package does not do is place your screens' own toolbar items. The guidance there — Back and Close at the top of the vertical axis, prominent actions next, the rest in their original groups — is for the AppBar inside each pane, which is yours.

Half open

While the device is half open the inner display is two planes at an angle, and a pane straddling the crease is bent across both. MasterDetailConfig.alignToFold (default true) puts the boundary on the fold instead of where paneRatio or a drag would have it, and parts the panes by the width of the crease rather than by PaneDecoration.gap. The divider stops being draggable while it holds — the hinge decided — and the app's own width comes back the moment the device is flat again, so the boundary moves once and once back.

The fold is read from MediaQuery.displayFeatures, and only a feature that is vertical, active and inside the pane area counts. The framework fills that list on Android and not yet on iOS, so a book-style Android foldable gets this today while iPhone Duo does not: nothing maps Apple's reservedRegions onto displayFeatures so far. Nothing here needs to change when it does.

Until it does, an app can say where the crease is itself:

AdaptiveShellConfig<AppRoute>(
  // A centre hinge, which is what iPhone Duo has.
  foldLocator: FoldMetrics.windowCentre,
  ...
)

foldLocator is consulted only where displayFeatures said nothing, so it takes nothing away from a device that reports a real fold — and it is the app asserting what the hardware is, not the package detecting it, which is why there is no default.

It is also not testable by hand yet — Xcode 27.1's simulator offers open, closed and rotate, with no half-opened posture and no hinge angle — so fold_split_test.dart injects the feature directly.

Tests

The package ships with a widget-test suite covering the invariants above: rotation and resize preservation, the three layouts, the master-detail split and its collapse, back handling and guards on every exit path, URL round trips, tab state, lazy branches, pane decoration geometry, divider dragging, immersive mode, keyboard insets and snack-bar scoping.

flutter test

Libraries

adaptive_nav
A Navigator 2.0 router that keeps screen state across window resizes.