duo_rail_kit 0.2.1
duo_rail_kit: ^0.2.1 copied to clipboard
Flutter layout helpers and an iOS plugin for system-directed side rails, vertical bar toolbars, and safe-area spacing on iPhone Duo.
duo_rail_kit #
A Flutter plugin for iPhone Duo: navigation rails on the physical edge iOS chooses, pushed and tab pages' back controls and actions in the system's vertical bar where UIKit puts them, the system's bar regions, and safe-area and FAB helpers. Navigation, styling, breakpoints, grids, SnackBars, overlays, and content/FAB avoidance stay with the application; the only runtime dependency is Flutter.
The example on the iOS 27.2 Simulator (details):


| Inner portrait: app bar in the top band | Outer display: right rail |
|---|---|
![]() |
![]() |
| Left Split View app: left rail | iPhone 17: bottom bar |
![]() |
![]() |
Requirements #
- Flutter
>=3.47.8, Dart^3.12.0. - iOS: Swift Package Manager, deployment target 15.0+, no CocoaPods.
- Native compilation: Xcode with the iOS 27.1 SDK, which declares
UITraitCollection.verticalBarEdgeandUIView.LayoutRegion.bar(onEdge:extent:); runtime access is guarded for iOS 27.1. - Other platforms use the application's width rule; no Android native code.
Flutter's generated registrant registers DuoRailKitPlugin (no Xcode edits);
the Swift package needs Flutter's FlutterFramework, so build it through a
Flutter iOS host.
Installation #
dependencies:
duo_rail_kit: ^0.2.1
Run flutter pub get and commit pubspec.lock. API docs are on
pub.dev.
Quick start #
Wrap the application once:
import 'package:duo_rail_kit/duo_rail_kit.dart';
import 'package:flutter/material.dart';
MaterialApp(
builder: (context, child) => VerticalBarEdge(
child: SideStatusBarInsets(child: child ?? const SizedBox.shrink()),
),
home: const Scaffold(body: Center(child: Text('Hello'))),
);
SideStatusBarInsets must be below VerticalBarEdge, whose edge it reads.
Keep both mounted whatever the device or layout; changing the wrapper structure
can recreate navigation and page state.
RailLayout makes the RailPlacement calls a rail needs; read it in the
shell, before changing content insets (the breakpoint and rail width are example
choices):
final data = MediaQuery.of(context);
final rail = RailLayout.of(
context,
showsRailByWidth: (windowWidth) => windowWidth >= 600,
railWidth: 80,
);
The example app shows every feature; example/lib/app.dart is the complete integration.
Features #
Values in this README are logical pixels (iOS points).
Rails #
The example's _DemoShell builds this as a VerticalBarShell:
RailSideRow(
railEdge: rail.edge,
rail: SizedBox(
width: rail.occupiedWidth,
child: MediaQuery(
data: data.copyWith(padding: rail.centredSafeArea),
child: NavigationRail(
minWidth: rail.width,
groupAlignment: rail.followsSystemBar ? 1 : 0,
scrollable: true,
selectedIndex: selectedIndex,
destinations: destinations,
),
),
),
content: MediaQuery(data: rail.removeRailEdge(data), child: content),
);
RailSideRowneither adjusts safe areas nor draws a rail; keep the wrappers around its content stable.centredSafeAreakeeps a system rail's destinations below the status display (RailPlacement.statusColumnBottom, 152), for a centred rail or a bottom-aligned group that can grow (large text, a keyboard).- Beside content holding a
Navigator(one per tab branch), a left rail is missing from the semantics tree, withRailSideRoworVerticalBarShell: the route'sModalBarrierblocks what is painted before it, up to the nearest semantics container. Passcontent: Semantics(container: true, child: content).
Top safe-area spacing #
SideStatusBarInsets raises the top padding and viewPadding to 16 in
windows with a status column and in the left Split View window.
FAB placement #
Always wrap the content in RailSideFab and change only enabled (the
example's _DemoShell and _DemoContent):
RailSideFab(
enabled: rail.followsSystemBar,
child: Builder(
builder: (context) => Scaffold(
floatingActionButtonLocation: RailSideFab.locationOf(context),
floatingActionButton: FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)),
),
),
);
When enabled, the FAB sits 24 above the content bottom.
Top band #
TopBarRegion.of returns the top bar region, the band beside the status
display in the Duo inner portrait top safe area; VerticalBarRegion.of, the
vertical bar's item region with its edge and topBarTop
(measured values).
An AppBar in the band needs no height of its own, as the Scaffold adds the
full top inset (the example's _DemoAppBar):
class BandAppBar extends StatelessWidget implements PreferredSizeWidget {
const BandAppBar({super.key, required this.band, required this.pane, required this.title});
final TopBarRegion? band; // TopBarRegion.of(context), read above the pane
final Rect pane; // the pane in window coordinates
final Widget title;
@override
Size get preferredSize => Size.fromHeight(band == null ? kToolbarHeight : 0);
@override
Widget build(BuildContext context) {
final band = this.band;
final data = MediaQuery.of(context);
return MediaQuery(
data: band == null ? data : data.copyWith(padding: band.paddingFor(pane)),
child: AppBar(toolbarHeight: band?.rect.height ?? kToolbarHeight, title: title),
);
}
}
A full-width pane on inner portrait gets EdgeInsets.fromLTRB(16, 24, 144, 0);
beside a rail, the pane is rail.contentRect(data.size).
MediaQuery.padding.top is unchanged, so dialogs keep the full inset. Taps in
the band reach Flutter.
Pushed-page toolbar #
VerticalBarPage draws a full-window pushed page's back control and actions in
the vertical bar column and tells its app bar what to leave out. Wrap the page
that a PageRoute builds, above its Scaffold (the example's _DetailPage):
class ArticlePage extends StatelessWidget {
const ArticlePage({super.key, required this.onShare});
final void Function(Rect? origin) onShare; // origin in window coordinates, or null
@override
Widget build(BuildContext context) => VerticalBarPage(
actions: [
VerticalBarAction(
id: 'share',
icon: const Icon(Icons.ios_share),
label: 'Share',
onPressed: (invocation) => onShare(invocation.anchor),
),
],
builder: (context, slots) => Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !slots.backInColumn,
centerTitle: slots.active ? false : null, // leading, as in UIKit
title: const Text('Article'),
actions: slots.active
? null
: [IconButton(tooltip: 'Share', icon: const Icon(Icons.ios_share), onPressed: () => onShare(null))],
),
body: const Center(child: Text('Article body')),
),
);
}
When the column is used
slots.active is true only when all of these hold:
| # | Condition | Not met, for example |
|---|---|---|
| 1 | enabled is true. |
Two-pane pages; Drawer pages (see Limitations). |
| 2 | VerticalBarColumn.of returns a column. |
iPhone, iPad, Android, Duo inner portrait, the first frame, right after a rotation. |
| 3 | The route is a PageRoute outside a Cupertino sheet, and the route holding the page's Navigator, if any, is opaque. |
Dialogs, sheets, menus, and Navigators inside them. |
| 4 | The page is as wide as the window (within 0.5). | Pages in a pane or beside a rail. |
| 5 | No ancestor VerticalBarPage uses the column. |
A page nested in one that does. |
| 6 | The OS inset on the column's edge, without SideStatusBarInsets' addition, is at least the column's width (within 0.5). |
The left Split View window (inset 0). |
| 7 | There is a back control or an action, and the column has room for them. | Too short a page. |
Controls and behaviour
- Default controls are
IconButtons, 38 square with 24 icons at any density; the overflow cell showsIcons.more_horiz. Application controls callcell.invokeand bring their own tooltip and semantics label. - Menus open beside the column's inner edge (UIKit's cover it); below a shell they cover the rail and outlive a branch pop. A choice reaches the actions once, as they were when the menu opened.
- Only cells take taps; gaps reach the page, or the rail below a shell. A
pushed page's column is a layer in its route: transitions (it slides with the
page), scrims,
BlockSemantics, and focus scope apply as to the page. A shell's cells sit in the shell's route above the rail and the content, staying with the shell and switching at once. - Screen readers and Tab visit the column before the page; the package adds no
FocusTraversalOrder, so orders around the page cannot move the column after it. - Cells keep their size at every text scale; in RTL only the back glyph
mirrors. The page keeps its
Statewhen the column comes, goes, or changes.
Tab shells #
To put tab pages' actions in the column above the rail:
-
Use
VerticalBarShellin place ofRailSideRow, with the same arguments, where MediaQuery still describes the window and the shell's box starts at the window's top-left corner and spans its width. -
Put
const VerticalBarRailEnd()last in theFlexthat holds the destinations, such asNavigationRail.trailing(withtrailingAtBottom: false, the default), and let the rail scroll. -
In a context below the shell, such as a
Builderin the rail, passVerticalBarShell.railPaddingOf(context, rail.centredSafeArea)as the rail'sMediaQuerypadding:rail: SizedBox( width: rail.occupiedWidth, child: Builder( builder: (context) => MediaQuery( data: data.copyWith(padding: VerticalBarShell.railPaddingOf(context, rail.centredSafeArea)), child: NavigationRail( // ... as in Rails, ending with the marker: selectedIndex: selectedIndex, destinations: destinations, trailing: const VerticalBarRailEnd(), ), ), ), ), -
Wrap each tab root in
VerticalBarPage, above the page's ownLayoutBuilders. A page with a back control, such as one pushed in a branch, uses the column only withVerticalBarShell.reservesBack, which keeps a back cell on every tab.
The shell uses the column when enabled is true, the rail is on the column's
edge, the status bar is visible, the window is inset for the column, and its
reserved cells fit: Duo inner landscape, outer portrait, and the right Split
View window, not the left one or outer landscape; these replace conditions 4
and 6. Branch containers must disable hidden branches' tickers, as go_router's
do.
Limitations #
Toolbar #
- Outside a
VerticalBarShell, root and tab routes are not detected: a wrapped window-wide page with actions draws the column over anything there, such as a rail. - Neither
VerticalBarPagenorVerticalBarShellmay sit under an intrinsic-size query (the page throws in debug builds even without a column). - The page's Navigator must start at the window's top-left corner (unchecked); below a shell, branch Navigators may sit beside the rail.
- The column stays tappable over Drawers, their scrim, and persistent bottom
sheets, and adds no Drawer button (
automaticallyImplyLeading: falsehides the app bar's); passenabled: falseon such pages. A fixed SnackBar's background, not its content, extends below the column. - A pushed page's cells do not move for a keyboard, so one that reaches the
column covers those below its top; below a shell, cells fold into the overflow
cell above a raised rail, except the reserved ones (one, or the back control
and one with
reservesBack). - No Large Content Viewer.
Tab shell behaviour #
- Inherited themes other than
Themebetween shell and page (such as an animatingIconTheme) reach the cells only when they are rebuilt. - During a back swipe in a branch, the upper page keeps the cells, inert, until the pop is decided; a pushed page that does not use the column leaves the previous cells drawn and inert until its push completes.
- Under a sheet, dialog, or menu on a branch Navigator, the cells stay drawn outside its scrim, inert.
- A rail that moves its destinations behind a relayout boundary inside it
(
NavigationRailhas none) gets new cells a frame late, or later with the repaint boundariesVerticalBarRailEnddescribes (such as a fixed-height scroll view around the marker'sFlex).
Native #
- The first frame, and every frame without the plugin, has no native edge and uses the width rule, so Dart tests do not prove native registration.
- The plugin samples regions with the edge and when the top or side bar layout guides move. A change to the active side's item region updates the toolbar even when the window size and safe-area insets stay the same.
- While the Duo inner display rotates,
VerticalBarRegion.ofcan return a mixed portrait region for a frame (measured values), whichVerticalBarColumnrejects;TopBarRegion.ofaccepts a mixed band only while the top view padding is still 82. - While a Split View window is resized, one payload can pair the new edge with the old window's region for about 5 ms.
Verification status #
Checked on the iOS 27.2 Simulator only; iOS 27.1 and devices are unchecked:
- Region output (measured values).
- On the outer display in portrait, the background location indicator moved the right item region's top from 174 to 218⅓ points. It returned to 174 when location updates stopped. The window size, safe-area insets and top guide stayed the same.
- The example's detail page matched UIKit's cells (icons within 3) on inner landscape and the right Split View window, kept its controls in the app bar elsewhere on the inner display and iPhone 17, and used the column on the outer display in portrait (first cell y 174–222) and landscape, where UIKit's placement is unmeasured; unit tests pair the recorded outer regions with a device fixture's safe area.
- Tab shells, against UIKit's measured tab screens (widget tests cover them too).
Unmeasured: UIKit's reading order and RTL in the column, and whether a keyboard reaches the column and how UIKit responds. Unverified: whether a cell or top-band tap also triggers status-bar scroll-to-top on a device, and whether UIKit shrinks the band when the status display widens.
Package tests do not verify an integration: run the application's analysis and tests, build an iOS host with the required SDK, check plugin registration, then Duo outer and inner orientations, both Split View sides, rotation, folding, keyboards, large text, state preservation, and ordinary phone, tablet, and Android layouts.
Reference #
API index #
| Layer | Areas | APIs |
|---|---|---|
| core | Physical edges | HorizontalEdge |
| edge | Native edge, system bar regions | VerticalBarEdge, VerticalBarEdgeScope, TopBarRegion, VerticalBarRegion, BarRegionScope |
| layout | Top spacing, rails, FAB placement | SideStatusBarInsets, RailLayout, RailPlacement, RailSideRow, RailSideFab, RailSideFabLocation |
| toolbar | Pushed pages, tab shells, UIKit-matched column geometry | VerticalBarPage, VerticalBarSlots, VerticalBarBack, VerticalBarAction, VerticalBarMenuItem, VerticalBarInvocation, VerticalBarCell, VerticalBarShell (railPaddingOf), VerticalBarRailEnd, VerticalBarColumn; typedefs VerticalBarActionCallback, VerticalBarCellBuilder, VerticalBarItemBuilder |
Measured values #
Measured on the iOS 27.1 Simulator:
| Duo configuration | System edge and column |
|---|---|
| Outer portrait / inner landscape | Right, 84 |
| Outer landscape | Left or right, 84, status bar hidden |
| Split View, left / right window | Left, no column / right, 84 |
| Inner portrait | None; the width rule applies |
Recorded through the example on the iOS 27.2 Simulator, 2026-11-04:
| Duo screen (window) | TopBarRegion.of |
VerticalBarRegion.of |
|---|---|---|
| Inner portrait (669 × 951) | x 16–525, y 24–72, safeTop 82 |
null |
| Inner landscape (951 × 669) | null | right, x 879–927, y 124–635 |
| Left Split View app (469 × 669) | null | left, x 24–72, y 16–635 |
| Right Split View app (469 × 669) | null | right, x 397–445, y 124–635 |
| Outer portrait (466 × 678) | null | right, x 394–442, y 174–644 |
| Outer landscape, column right (678 × 466) | null | right, x 606–654, y 2.3–384 |
| Outer landscape, column left (678 × 466) | null | left, x 24–72, y 82–432 |
iPhone 17 (both orientations) and iPad Air 11-inch (M4) reported neither;
topBarTop was 24 wherever a vertical bar region was reported.
Rotating the Duo inner display on the iOS 27.2 Simulator, one payload mixed old
and new values for up to about 30 ms: towards portrait, the old right edge with a
portrait vertical bar region (x 597–645, y 82–917); towards landscape, a band for
the old safe area (x 16–807, safeTop 82).
Placement
Cells are 48 square, 52 apart within a group and 12 between groups, from the
region's top (or a lower topBarTop), centred on the region (x 903 in inner
landscape), and end above the page's bottom view padding. Below a shell they
are centred on the rail, at UIKit's x with a systemRailWidth rail, and end 12
above its first destination. No group forms at the column's bottom, where UIKit
puts toolbars and tabs.
The column's placement is unit-tested against UIKit navigation bars on the
iOS 27.2 Simulator; all rows but the last match ([1, 2] = the back control,
then two actions):
| Window and input | Result |
|---|---|
Inner landscape, [1, 2] |
Back y 124–172 (button x 884–922); actions y 184–284 |
Inner landscape, [1, 10] |
7 actions, then the overflow cell (y 548–596) holding 3 (UIKit's More) |
Inner landscape, [1, 2], cells ending above y 271 (seven tabs) |
Back y 124–172; the actions group is only the overflow cell (y 184–232) |
Right Split View, [1, 2] |
x 397–445; y 124–172, 184–284 |
Left Split View, [1, 2] |
x 24–72; y 24–72, 84–184 (geometry only: condition 6) |
Left Split View, no topBarTop |
Cells start at y 16, 8 above UIKit |
Native channel #
Channel com.zawasystem.fleet/vertical_bar_edge: request getEdge,
notification edgeChanged. Region values are in the Flutter view's
coordinates (physical left/right), rounded to physical pixels except
viewWidth and viewHeight (the view's size).
| Key | Value | Present |
|---|---|---|
edge |
"left", "right", or "none" |
Always |
statusBarHidden |
bool | Always |
sequence |
int, increasing | Always |
topBar |
{left, top, right, bottom, safeTop, viewWidth, viewHeight} |
iOS 27.1+, phone idiom, when bar(onEdge: .top, extent: 48) is non-empty, its top is at least 0 and above the top inset, and its bottom at most the inset + 0.5 |
verticalBar |
{left, top, right, bottom, viewWidth, viewHeight, topBarTop?} |
iOS 27.1+, phone idiom, when edge is left or right and bar(onEdge: edge, extent: 48) is non-empty and inside the view; topBarTop (top of bar(onEdge: .top, extent: 48)) only when at least 0 and above the bottom |
Dart revalidates regions and applies only newer sequence numbers, whatever the arrival order.
Maintenance #
CONTRIBUTING.md has the commands, native checks, and CI; CHANGELOG.md the changes; docs/publication.md the release steps; SECURITY.md how to report a vulnerability. Licensed under the MIT License; template, font, and icon attributions are in THIRD_PARTY_NOTICES.md.




