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
### Breakingin 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.

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.
Deep links
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/railBuilderreplace the Material default completely — your own pill, your own icons, your own destinations. They receive branch indices, so nothing is mapped twice, and may returnnullto hide the chrome for a frame. A custom rail has to be exactlyAdaptiveShellConfig.railWidthwide: the layout is computed from that number, and a rail that disagrees with it throws the detail pane width off by the difference.drawerBuilderadds aScaffold.drawer, withscaffoldKeyto open it from anywhere. It receives whether the layout currently uses the rail.showsChromehides 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: falsekeeps a branch navigable but gives it no destination (a profile or auth branch, entered programmatically).extendBodyBehindBarextends the body under a floating bar. It is off by default, because under the opaque defaultNavigationBarthe content would simply disappear.branchFadeDurationturns 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)andStatusColumnScope.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.dartandduo_poses_test.dartpin 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
masterMinWidthanddetailMinWidththat fit, the wayexample/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
- A Navigator 2.0 router that keeps screen state across window resizes.