declarative_nav
A small Flutter package for declarative navigation (Navigator 2.0 pages) and screen-local overlays (dialog/bottom-sheet style) without using:
Navigator.push/Navigator.popshowDialogshowModalBottomSheet
The package is state-management agnostic (no Riverpod/Bloc dependency). You can drive navigation and overlays with anything: setState, Riverpod, Bloc, Provider, etc.
Developed by Finite Field, K.K..
Key idea
- Pages are data:
PageEntrycontains no Widget types. - Widgets are created by a resolver you provide to the Navigator widget.
This makes your navigation state serializable/portable and keeps UI separate.
What you get
Core
PageEntry: page metadata (no Widget types)DeclarativePagesNavigator: renders a fully-declarativeList<PageEntry>using yourbuildPageresolverOverlayRequest:DialogRequest/BottomSheetRequestwithkey,barrierDismissible, and optionaldataScreenOverlayHost: minimal overlay host (single overlay, no animations)AnimatedScreenOverlayHost(optional): animated barrier + blur- Dialog: fade + scale
- BottomSheet: slide-up + fade
DeclarativePagesBackScope: back handling for fully-declarative pages + overlaysScreenKey: simple value object for typed screen IDs (optional)
Tabs extension (optional)
DeclarativeTabsScaffold: fully-declarative tabs (per-tab pages insetState)TabId: simple value object for tab identityTabPageKey: a simple record key typedef for(TabId, String)
Notes / constraints
PageEntry.keymust be unique within a stack (used as theNavigatorkey).nameis required but can be''if unused.- Overlays are single at a time (
OverlayRequest?); stack multiple overlays yourself if needed. - Use
canPopTop/canPopTopForTabto block back-swipe when an overlay is visible. - For tabs,
pagesByTabmust include each tab and each list must be non-empty (root page). DeclarativeTabsScaffoldre-taps pop to root; back order is overlay -> pop -> first tab ->onBackAtRoot.
Fully declarative pages (setState)
class AppRoot extends StatefulWidget {
const AppRoot({super.key});
@override
State<AppRoot> createState() => _AppRootState();
}
class _AppRootState extends State<AppRoot> {
OverlayRequest? _overlay;
late List<PageEntry> _pages;
@override
void initState() {
super.initState();
_pages = const [
PageEntry(key: 'home', name: '/home'),
];
}
void _goDetail() {
setState(() => _pages = [..._pages, const PageEntry(key: 'detail', name: '/detail')]);
}
void _popTop() {
if (_pages.length <= 1) return;
setState(() => _pages = _pages.sublist(0, _pages.length - 1));
}
void _openDialog() {
setState(() => _overlay = DialogRequest(key: 'hello', barrierDismissible: true));
}
void _dismissOverlay() {
setState(() => _overlay = null);
}
Widget _buildPage(BuildContext context, PageEntry page) {
switch (page.key) {
case 'home':
return HomePage(onGoDetail: _goDetail, onOpenDialog: _openDialog);
case 'detail':
return const DetailPage();
default:
return const SizedBox.shrink();
}
}
@override
Widget build(BuildContext context) {
return DeclarativePagesBackScope(
pages: _pages,
popTopPage: _popTop,
isOverlayVisible: _overlay != null,
dismissOverlay: _dismissOverlay,
child: AnimatedScreenOverlayHost(
overlay: _overlay,
onDismiss: _dismissOverlay,
overlayBuilder: (context, req, dismiss) => switch (req) {
DialogRequest(key: 'hello') => Padding(
padding: const EdgeInsets.all(16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Hello'),
const SizedBox(height: 12),
ElevatedButton(onPressed: dismiss, child: const Text('Close')),
]),
),
_ => null,
},
child: DeclarativePagesNavigator(
pages: _pages,
buildPage: _buildPage,
onPopTop: _popTop,
canPopTop: () => _overlay == null,
),
),
);
}
}
Fully declarative tabs + screen-local overlays per (tab, page)
If you want screen-local overlay state scoped to (tab, pageKey) using setState, keep a map:
final Map<TabPageKey, OverlayRequest?> overlaysByScreen = {};
Where TabPageKey is a record typedef: (TabId, String).
Then show an overlay for the current tab's top page:
final pages = pagesByTab[currentTab]!;
final key = (currentTab, pages.last.key);
setState(() {
overlaysByScreen[key] = DialogRequest(key: 'hello');
});
And read it when rendering:
final overlay = overlaysByScreen[key];
Dismiss by removing or setting to null.
Anti-patterns
-
Mixing imperative and declarative navigation
Don’t useNavigator.push/popfor the same stack you control with yourList<PageEntry>. -
Using Widget types inside navigation state
Keep pages as data (PageEntry) and build UI via resolvers.
License
MIT