Zero Adaptive Link
English | 简体中文
Adaptive navigation for Flutter: write one onTap and the plugin does the right thing at every window width — push a new page on mobile, expand a sidebar pane on web and desktop. URL-addressable, deep-linkable, zero runtime dependencies.
🔔 Upgrade recommended:
0.5.0adds nestedZeroShell(aZeroRoutecan host its own tabs / panes viaZeroRoute.shell+shellBuilder), real web<a href>anchors so links open in a new tab and preview correctly, and scroll & focus restoration across breakpoint crossings and browser history. It also keepsZeroLinkaccessible,ZeroRouter.onNavigation,ZeroRoute.loader, swipe-to-dismiss,ZeroNavigationChrome, branch breadcrumbs, and explicitpreferredTab/ZeroLink.tabIndex. All users are encouraged to upgrade to the latest version (^0.5.0).
🌐 Official Website · 📦 View on pub.dev · 🔗 View on GitHub
Table of Contents
Features
- One
onTap, every width — wrap any tappable widget withZeroLink(or callZeroNavimperatively); the plugin decides by window width, not device type, so foldables, small desktop windows and browser zoom all behave correctly. - Breakpoints by window width —
< 600compact pushes a page,600 – 839medium pushes a page (single column),>= 840expanded expands or updates the right sidebar pane. - Master-detail pane semantics — the detail pane slides in from the side set by
paneSide(right by default, or left to mirror the layout: master on the right, detail from the left); a draggable resize handle splits the two columns; three or more columns are deliberately out of scope. - Animated pane transitions — the detail pane slides in on its first expansion, a deeper push slides the viewport left by one column (the replaced master exits, the new pair slides in), and a pop slides it back.
- State survives width changes — pages and expanded panes hand the same subtree over in the same frame when the window crosses a breakpoint, so scroll offsets and text input do not reset; the stable key is exposed as
ZeroRouteState.pageKey. - Scroll & focus restoration —
PageStorage-based scroll restoration keyed byZeroRouteState.restorationId, plus primary focus restore across breakpoint crossings and browser history changes; opt out withrestoreScrollOffset/restoreFocus. - Expand left or right — pick
SlidingPanesSide.right(master on the left) orSlidingPanesSide.left(master on the right) viapaneSide, and flip it at runtime withpaneSideListenablewithout rebuilding the router. - Pane sizing with a ceiling — set
leftPaneFractionfor the initial split andmaxLeftPaneWidthso the master pane still scales with the window but stops at a sane width on ultra-wide displays (the detail pane absorbs the remainder; dragging respects the ceiling too). - Idempotent focus — tapping an already visible pane focuses it instead of stacking a duplicate.
- URL-addressable and deep-linkable — every screen is in the route table, so its URL can express it; on the web the hash strategy (
/#/products/42) keeps browser back, refresh and deep links on the same location pipeline as in-app navigation (GitHub Pages needs no 404 fallback). - Real web anchors —
ZeroAnchorrenders a genuine DOM<a href>on web (viadart:js_interop), so "open in new tab" and link previews work while clicks still route through the plugin;ZeroRouter.canonicalHref(location)returns the shareable hash URL. - Declarative route table — nested routes,
:paramsegments, route names, titles, router-level and route-level redirects,onExithooks,fullscreen/fullscreenDialogoverlays, custom transitions and anerrorBuilder. - Nested shells — a
ZeroRoutecan host its ownZeroShellviashell+shellBuilder, so a branch can own a second level of tabs / panes; route tables nest arbitrarily deep and a deep link like#/settings/profilerestores every level. - Navigator-shaped verbs —
pushNamed/pop/maybePop/popUntil/canPopand friends share their names and signatures withNavigatorState, so migration costs nothing. - Compact chrome that behaves — the bottom
NavigationBaris rendered inside branch pages, so a bottom sheet opened withuseRootNavigator: falsecovers it; the system back button closes the sheet first. - Zero runtime dependencies — pure Dart, no platform channels, no native code;
ValueNotifier/InheritedWidgetonly. - Layout without the router —
SlidingPanes,SlidingPaneandBreadcrumbsare public and usable on their own.
Screenshots
Screenshots are not captured yet. Run the example (
cd example && flutter run -d chrome) and use the width preset chips (Phone 480 / Foldable 720 / Tablet 1023 / Desktop) to see all three behaviors side by side.截图尚未采集。运行示例(
cd example && flutter run -d chrome)并通过宽度预设 chips(Phone 480 / Foldable 720 / Tablet 1023 / Desktop)即可并排查看三种行为。
Installation
Pub.dev (Recommended)
dependencies:
zero_adaptive_link: ^0.5.0
GitHub
dependencies:
zero_adaptive_link:
git:
url: https://github.com/zero-labsco/zero-adaptive-link.git
ref: release/v0.5.0 # replace 0.5.0 with the version you need
Usage
One onTap, Every Width
import 'package:flutter/material.dart';
import 'package:zero_adaptive_link/zero_adaptive_link.dart';
void main() => runApp(MaterialApp.router(routerConfig: myRouter));
// Declarative: wrap any tappable widget. / 声明式:包裹任意可点击组件。
final listTile = ZeroLink(
location: '/products/42',
child: const ListTile(title: Text('Product 42')),
);
// Imperative: from any callback. / 命令式:任意回调中均可使用。
void openProduct(BuildContext context) {
ZeroNav.of(context).open('/products/42');
}
The same tap behaves differently per width:
| Window width | Class | onTap behavior |
|---|---|---|
< 600 |
compact | push a new page (classic Navigator stack) |
600 – 839 |
medium | push a new page (single column, different shell chrome) |
>= 840 |
expanded | expand / update the right sidebar pane (master-detail) |
Route Table
final router = ZeroRouter(
initialLocation: '/products',
routes: [
ZeroShell(
builder: (context, shell, child) => shell.isExpanded
? Row(children: [
NavigationRail(
selectedIndex: shell.currentIndex,
onDestinationSelected: (i) => shell.goTab(i),
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.list),
label: Text('Products'),
),
],
),
Expanded(child: child),
])
: Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: (i) => shell.goTab(i),
destinations: const [
NavigationDestination(icon: Icon(Icons.list), label: 'Products'),
],
),
),
tabs: [
ZeroTab(routes: [
ZeroRoute(
path: '/products',
title: (state) => 'Products',
builder: (context, state) => const ProductListPage(),
routes: [
ZeroRoute(
path: ':id',
title: (state) => 'Product ${state.pathParameters['id']}',
builder: (context, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
],
),
]),
],
),
],
);
Rules: child paths are relative to their parent; :param matches a single path segment; the first match wins; every page must be in the table so its URL can express it.
Navigation Verbs
final nav = ZeroNav.of(context);
nav.open('/products/42'); // adaptive push / pane expand
await nav.pushNamedAndRemoveUntil('/cart', (match) => false);
nav.maybePop(); // PopScope first, then onExit
nav.popUntil((match) => match.location == '/products');
nav.location; // '/products/42'
nav.locationListenable; // ValueListenable<String>
Layout Without the Router
SlidingPanes(children: [masterPane, detailPane]); // 1/2 columns
Breadcrumbs(items: ['/products', '/products/42'], onNavigate: (i) => …);
The detail pane expands to the right by default. Mirror the layout — master on
the right, detail sliding in from the left — with side:
SlidingPanes(
side: SlidingPanesSide.left, // master on the right
maxLeftPaneWidth: 360, // stop growing on ultra-wide windows
children: [masterPane, detailPane],
);
With the router, set paneSide on ZeroRouter (or drive it live via
paneSideListenable) to flip the expanded layout at runtime, and
leftPaneFraction / maxLeftPaneWidth to control the split.
默认 detail pane 向右展开。要镜像布局——master 在右、detail 从左滑入——用
side;用 maxLeftPaneWidth 可让 master 在超宽窗口停止变宽:
SlidingPanes(
side: SlidingPanesSide.left, // master 在右
maxLeftPaneWidth: 360, // 超宽窗口不再继续变宽
children: [masterPane, detailPane],
);
配合路由时,在 ZeroRouter 上设置 paneSide(或用 paneSideListenable
实时驱动)切换展开方向,并用 leftPaneFraction / maxLeftPaneWidth
控制分栏比例。
API Reference
ZeroRouter
| Parameter | Type | Description |
|---|---|---|
initialLocation |
String |
Location to resolve on first paint |
redirect / redirectLimit |
ZeroRouterRedirect? / int |
Router-level redirect and its hop limit |
errorBuilder |
ZeroErrorBuilder? |
Rendered when no route matches |
paneSide / paneSideListenable |
SlidingPanesSide / ValueListenable<SlidingPanesSide>? |
Which side the detail pane expands from, optionally driven live |
leftPaneFraction / maxLeftPaneWidth |
double / double? |
Initial split and a pixel ceiling for the master pane |
slideDuration / slideCurve |
Duration / Curve |
Pane transition timing |
showBreadcrumbs / breadcrumbsBuilder |
bool / ZeroBreadcrumbsBuilder? |
Expanded-width breadcrumb bar |
onNavigation |
void Function(String location)? |
Fired after every successful location change |
restoreScrollOffset |
bool |
PageStorage-based scroll restoration (default true) |
restoreFocus |
bool |
Restore primary focus across breakpoint crossings / history changes (default true) |
canonicalHref(location) |
static String |
Shareable hash URL for a location (#/products/42) |
ZeroLink
| Parameter | Type | Description |
|---|---|---|
location |
String |
Target location |
child |
Widget |
The tappable content |
onTap |
VoidCallback? |
Overrides the default open behavior |
cursor |
MouseCursor? |
Defaults to a hand cursor on web / desktop |
mode |
ZeroLinkMode |
push (default), replacement, popAndPush or fullscreen |
tabIndex |
int? |
Open the location in this tab specifically |
ZeroAnchor
| Parameter | Type | Description |
|---|---|---|
location |
String |
Target location (rendered as #location in the href) |
label |
String |
The anchor text |
mode |
ZeroLinkMode |
Kept for API parity with ZeroLink; anchors always use link semantics |
Renders a genuine DOM <a href> on web so "open in new tab" and link previews work; falls back to a plain ZeroLink off-web.
ZeroNav
| Member | Description |
|---|---|
open(location) |
Adaptive push / pane expand |
pushNamed / pushReplacementNamed / pushNamedAndRemoveUntil / popAndPushNamed |
Navigator-shaped verbs |
pop / maybePop / popFrom / maybePopFrom / popUntil / canPop |
Pop verbs |
namedLocation(name, pathParameters:, queryParameters:) |
Build a location from a named route |
refresh() |
Re-run redirects (e.g. after an auth change) |
location / locationListenable |
Current location, plus a listenable for reactive UI |
ZeroRoute
| Parameter | Type | Description |
|---|---|---|
path |
String |
Pattern relative to the parent (:param supported) |
name |
String? |
Name for namedLocation |
builder |
Widget Function(BuildContext, ZeroRouteState) |
Page content |
title |
String Function(ZeroRouteState)? |
Evaluated once at match time |
redirect |
FutureOr<String?> Function(ZeroRouteState)? |
Route-level redirect |
onExit |
FutureOr<bool> Function()? |
Veto hook consulted before popping |
fullscreen / fullscreenDialog |
bool |
Cover the shell at any width |
hidesBottomBarWhenPushed |
bool |
Hide the compact bottom bar (default true) |
opaque / barrierColor / barrierDismissible |
— | Modal route properties |
transitionsBuilder |
RouteTransitionsBuilder? |
Custom page transitions |
routes |
List<ZeroRoute> |
Nested routes |
shell |
ZeroShell? |
Host a nested shell inside this route |
shellBuilder |
Widget Function(BuildContext, ZeroRouteState, Widget)? |
Builder for a route that hosts shell; child is the nested shell (mutually exclusive with builder) |
Breakpoints
| Field | Default | Meaning |
|---|---|---|
compactMaxWidth |
600 |
Below → compact |
expandedMinWidth |
840 |
At or above → expanded |
Contributing
Contributions are welcome! Please read the Contributing Guidelines before submitting issues or pull requests.
License
Copyright (c) 2026 Zero Labs Co. (AmisKwok). This project is licensed under the Mozilla Public License 2.0 (MPL-2.0) — see the LICENSE file for details. The copyright notice and additional statements (no warranty, no endorsement) live in NOTICE.
- Commercial use is allowed. Use, modification and closed-source distribution are permitted.
- Modified the plugin? The files you modified must be published in source form under MPL-2.0. Your own app does not need to be open-sourced.
- Used it unmodified? No source disclosure is required.
- No endorsement. "Zero Labs Co.", "zero_adaptive_link", the logo / mascot artwork, and the author's name (AmisKwok) may not be used to endorse or promote derived products, or to imply sponsorship or affiliation, without prior written permission.
- No warranty, no liability. The copyright holder provides no warranty and accepts no liability for any modified or derivative version; modified versions must be clearly marked as modified.
This plugin is provided "as is", without warranty of any kind. The author assumes no responsibility or liability for the functionality, security, or any consequences arising from the use of modified versions or derivative projects.
Libraries
- zero_adaptive_link
- Adaptive navigation for Flutter: one
onTap, the right behavior everywhere. Flutter 的自适应导航:同一套onTap,在任意平台都做出正确行为。