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.pop
  • showDialog
  • showModalBottomSheet

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: PageEntry contains 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-declarative List<PageEntry> using your buildPage resolver
  • OverlayRequest: DialogRequest / BottomSheetRequest with key, barrierDismissible, and optional data
  • ScreenOverlayHost: 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 + overlays
  • ScreenKey: simple value object for typed screen IDs (optional)

Tabs extension (optional)

  • DeclarativeTabsScaffold: fully-declarative tabs (per-tab pages in setState)
  • TabId: simple value object for tab identity
  • TabPageKey: a simple record key typedef for (TabId, String)

Notes / constraints

  • PageEntry.key must be unique within a stack (used as the Navigator key). name is required but can be '' if unused.
  • Overlays are single at a time (OverlayRequest?); stack multiple overlays yourself if needed.
  • Use canPopTop / canPopTopForTab to block back-swipe when an overlay is visible.
  • For tabs, pagesByTab must include each tab and each list must be non-empty (root page).
  • DeclarativeTabsScaffold re-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 use Navigator.push/pop for the same stack you control with your List<PageEntry>.

  • Using Widget types inside navigation state
    Keep pages as data (PageEntry) and build UI via resolvers.

License

MIT

Libraries

declarative_nav