squid 0.1.1 copy "squid: ^0.1.1" to clipboard
squid: ^0.1.1 copied to clipboard

A simple, reliable and extensible declarative navigator for Flutter. The whole navigation is a plain list of routes you can rewrite at any moment.

Squid: A Declarative Navigator for Flutter #

Pub Actions Status Coverage License: MIT Linter GitHub stars

The whole navigation is a list of routes. Rewrite the list — the screens follow.

controller.change((stack) => <NavigationRoute>[
      Routes.home,
      const ProductRoute(42),
    ]);

There is no hidden state, no route table to keep in sync, no imperative push / pop pairs to balance. There is a list, a widget that renders it, and the guards that decide what the list is allowed to look like.


Table of contents #

Why #

Most routers describe the navigation as a set of destinations and let the user walk between them one command at a time. It works until the moment the application needs to say something about the navigation as a whole: "a signed out user sees the sign in screen and nothing else", "the paywall closes the moment the subscription is active", "dialogs always float above the screens", "going back never leaves the tab".

With a declarative stack those rules are ordinary code over an ordinary list — written once, applied everywhere, testable without a widget tree.

  • 🧾 The state is a list. List<NavigationRoute>, nothing else. Whatever you can do to a list, you can do to your navigation.
  • 🛡️ Guards. Pure functions that validate every change of the stack and re-run whenever the state they depend on changes.
  • 🧩 Routes are values. An enum for the simple ones, a sealed class for the ones with parameters. Exhaustive switch, real types, no strings.
  • 🪟 Dialogs and bottom sheets are routes too. No showDialog running next to the navigation and getting out of sync with it.
  • 🗂️ Many navigators, one application. One controller per tab, and any tab can rewrite the stack of any other tab.
  • 📐 Adaptive. A guard can read the window size, the theme or an inherited scope, and re-runs by itself whenever any of them changes.
  • 🔌 No magic. No code generation, no singletons, no global state, no dependencies besides Flutter.
  • 🧪 Testable. A controller is a plain ChangeNotifier: most of the navigation can be tested without pumping a single widget.

Installation #

dependencies:
  squid: ^0.1.0

Quick start #

1. Declare the routes #

An enum is enough when the routes have no parameters — the name, the key and the equality come for free:

enum Routes with NavigationRoute {
  home,
  catalog,
  settings;

  @override
  Widget build(BuildContext context) => switch (this) {
        Routes.home => const HomeScreen(),
        Routes.catalog => const CatalogScreen(),
        Routes.settings => const SettingsScreen(),
      };
}

2. Create a controller #

final controller = NavigationController(<NavigationRoute>[Routes.home]);

3. Render it #

MaterialApp(
  home: NavigationView(controller: controller),
);

4. Navigate #

context.navigation.push(Routes.settings);
context.navigation.pop();
context.navigation.change((stack) => stack.withoutTag(kModalTag));

That is the whole package. Everything below is the detail.

Routes #

A route is an immutable description of a screen, not a command that opens it. The only required member is build.

Member Default What it is for
build — The content of the route
name enum value name / type name Debugging, analytics, the default key
key ValueKey(name + arguments) Identity inside the stack
tags {} Addressing groups of routes in guards
priority 0 Ordering, applied by the PriorityGuard
arguments {} Untyped parameters, mostly for analytics
page MaterialPage Transitions, dialogs, sheets

Routes with parameters #

Use a sealed class and keep the parameters typed. The only rule is that such routes must override key, otherwise all the products in the world are the same entry of the stack:

sealed class AppRoute with NavigationRoute {
  const AppRoute();
}

final class ProductRoute extends AppRoute {
  const ProductRoute(this.id);

  final int id;

  @override
  String get name => 'product';

  @override
  LocalKey get key => ValueKey<String>('product/$id');

  @override
  Widget build(BuildContext context) => ProductScreen(id: id);
}

Two routes with the same key are the same screen: Flutter keeps its state instead of recreating it, and pushing an already present route moves it to the top of the stack instead of duplicating it.

Dialogs and bottom sheets #

A dialog is a route with another page, and the package ships the two common ones:

final class ConfirmRoute extends AppRoute with DialogRouteMixin {
  const ConfirmRoute(this.question);

  final String question;

  @override
  Widget build(BuildContext context) => AlertDialog(
        content: Text(question),
        actions: <Widget>[
          TextButton(
            onPressed: () => context.navigation.pop(false),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => context.navigation.pop(true),
            child: const Text('Confirm'),
          ),
        ],
      );
}

final confirmed = await context.navigation.pushForResult<bool>(
  const ConfirmRoute('Delete the account?'),
);

pushForResult completes with the value passed to pop, remove or the familiar Navigator.pop(context, value), or with null when the route is closed in any other way — a tap on the barrier, the system back button or a guard. Both mixins tag their routes with kModalTag and give them a positive priority, so controller.removeTag(kModalTag) closes every popup at once and the PriorityGuard keeps them above the screens.

BottomSheetRouteMixin works the same way, and NoTransitionRouteMixin removes the animation. For anything else override page and return whatever Page you like — including your own PageRoute with a custom transition.

change is the only method that matters, the rest are shortcuts:

controller.change((stack) => <NavigationRoute>[...stack, Routes.settings]);

controller.push(Routes.settings);           // add on top
controller.pop();                           // remove the visible route
controller.popUntil((route) => route is ProductRoute);
controller.popToRoot();                     // keep the root only
controller.replaceTop(Routes.catalog);      // swap the visible route
controller.removeTag(kModalTag);            // close every popup
controller.removeWhere((route) => route is ProductRoute);
controller.stack = <NavigationRoute>[Routes.home, const ProductRoute(1)];

A deep link is not a special case — it is a stack:

void openDeepLink(Uri uri) => controller.stack = <NavigationRoute>[
      Routes.home,
      Routes.catalog,
      ProductRoute(int.parse(uri.pathSegments.last)),
    ];

Inside a widget the controller is one extension away:

context.navigation             // the closest controller
context.maybeNavigation        // ...or null
NavigationScope.stackOf(context) // the stack, rebuilds on every change

The stack itself has a small vocabulary of helpers that never modify the receiver: topOrNull, containsKey, containsTag, containsType<T>(), findKey, findName, findType<T>(), whereTag, withoutTag, withoutType<T>(), without, withRoute.

Guards #

A guard receives the stack the application wants and returns the stack it gets. It runs on every change and every time the state it depends on notifies, which is exactly what makes the rules of the application declarative:

final controller = NavigationController(
  <NavigationRoute>[Routes.home],
  guards: <NavigationGuard>[
    RootGuard(() => Routes.home),
    GateGuard(
      isOpen: () => authentication.isSignedIn,
      tag: 'unauthenticated',
      closed: () => <NavigationRoute>[Routes.signIn],
      opened: () => <NavigationRoute>[Routes.home],
    ),
    const PriorityGuard(),
    const LimitGuard(16),
  ],
  // Re-runs the guards when the user signs in or out.
  revalidate: authentication,
);

Nothing above says "navigate to the sign in screen". Signing out changes a flag; the guard rewrites every stack that violates the new rule. That is the whole point of the declarative model: the rules live in one place and cannot be forgotten at a call site.

Guard Rule
PriorityGuard Sorts the stack by priority: modals float, roots sink
RootGuard Keeps a route at the bottom and the stack non empty
LimitGuard Limits the depth, dropping the oldest routes in the middle
GateGuard Keeps the application behind a gate until a condition holds
ContextGuard Base class for the rules that depend on the widget tree

A guard is either a function or a class with state:

// A function.
NavigationGuard((controller, stack) => stack.withoutType<DraftRoute>());

// A class, when it needs a dependency or a memory.
class PaywallGuard implements NavigationGuard {
  PaywallGuard(this._payment);

  final PaymentController _payment;
  bool _shown = false;

  @override
  NavigationStack call(
    NavigationController controller,
    NavigationStack stack,
  ) {
    if (_payment.hasSubscription) return stack.withoutTag('paywall');
    if (_shown || stack.containsTag('paywall')) return stack;
    _shown = true;
    return <NavigationRoute>[...stack, Routes.paywall];
  }
}

Guards and the widget tree #

Not every rule is about the application state. "The side pane exists only on a wide window", "this screen is only for the dark theme", "the repository lives in an inherited scope" — those need the context. Extend ContextGuard and it is right there:

class AdaptiveGuard extends ContextGuard {
  const AdaptiveGuard();

  @override
  NavigationStack guard(BuildContext context, NavigationStack stack) =>
      MediaQuery.sizeOf(context).width >= 900
          ? stack.withRoute(const SidePaneRoute())
          : stack.withoutTag('side-pane');
}

Nothing else has to be wired up: reading an inherited widget from a guard subscribes the navigator to it, so resizing the window, switching the theme or updating the scope re-runs the guards with the new value. A navigator whose guards read nothing from the context keeps no dependencies at all and pays nothing for this.

The context of the widget rendering the controller is also available directly, as controller.context, for the guards that need it only sometimes. It is null while no widget is mounted — during the validation of the initial stack, for example — and ContextGuard is simply skipped until then.

Three things worth knowing:

  1. The order matters. Guards are applied one after another, each one receiving the result of the previous one, so the guard that must have the last word goes last.
  2. Returning controller.stack cancels the change. The controller has not committed anything yet when the guards run, so the previous stack is always available.
  3. A throwing guard is skipped, reported to FlutterError and does not break the navigation.

Observers #

Guards decide, observers watch. Analytics, logging and the window title belong here:

class AnalyticsObserver with NavigationObserver {
  AnalyticsObserver(this._analytics);

  final Analytics _analytics;

  @override
  void onAdd(NavigationController controller, NavigationRoute route) =>
      _analytics.logScreenView(route.name);
}

NavigationController(
  <NavigationRoute>[Routes.home],
  observers: <NavigationObserver>[
    AnalyticsObserver(analytics),
    if (kDebugMode) const NavigationLogger(),
  ],
);

Tabs #

A tab is just another stack, so a tabbed application is a map of controllers:

final tabs = NavigationTabsController<AppTab>(
  tabs: <AppTab, NavigationController>{
    AppTab.shop: NavigationController(<NavigationRoute>[Routes.catalog]),
    AppTab.cart: NavigationController(<NavigationRoute>[Routes.cart]),
  },
);

ListenableBuilder(
  listenable: tabs,
  builder: (context, _) => Scaffold(
    body: IndexedStack(
      index: tabs.activeIndex,
      children: <Widget>[
        for (final controller in tabs.tabs.values)
          NavigationView(controller: controller),
      ],
    ),
    bottomNavigationBar: NavigationBar(
      selectedIndex: tabs.activeIndex,
      onDestinationSelected: (index) => tabs.select(AppTab.values[index]),
      destinations: const <Widget>[...],
    ),
  ),
);

Because a controller is an ordinary object, one tab can rewrite the stack of another one:

tabs[AppTab.cart].push(ProductRoute(id));
tabs.select(AppTab.cart, popToRoot: false);

The back button is routed automatically: the inactive tabs never react to it, the active tab pops its stack, and when it has nothing left to pop the controller returns to the previously selected tab instead of closing the application. Selecting the tab that is already active pops it to the root — pass popToRoot: false to keep the stack.

The back button #

NavigationView handles the system back button by asking the underlying Navigator to pop, so PopScope and the imperative routes opened on top of the declarative stack keep working. Override the behaviour when a screen needs something else:

NavigationView(
  controller: controller,
  onBackButtonPressed: (controller) async {
    if (controller.top.tags.contains('checkout')) {
      controller.stack = <NavigationRoute>[Routes.home];
      return true;
    }
    return controller.maybePop();
  },
);

Set interceptBackButton: false for a view that must never react to it.

Views nested into the routes of another view — a multi step flow with its own navigator, the tabs of a shell screen — are asked first: the press goes to the deepest view that is actually visible, and reaches the outer view only when the nested one cannot go back any further. A nested view covered by another route or by a dialog of the outer navigator never reacts.

When a guard refuses to remove a route the user has just closed (a swipe back, the button of an AppBar), the route is brought back on screen, so the widgets never drift away from the stack of the controller.

Helpers of your application #

Squid adds a single getter to BuildContext — context.navigation, the raw controller. Everything else belongs to the application: its own vocabulary, its own shortcuts. The cheapest way to add them is an extension type over the context: a zero cost wrapper that keeps the namespace of BuildContext clean and can grow helpers squid knows nothing about.

extension type AppNavigation(BuildContext _context) {
  NavigationController get controller => _context.navigation;

  void change(NavigationChange fn) => controller.change(fn);

  void push(NavigationRoute route) => controller.push(route);

  bool pop([Object? result]) => controller.pop(result);

  /// Closes every popup at once, whatever opened it.
  void popModals() {
    final controller = _context.maybeNavigation;
    // The declarative dialogs and sheets.
    controller?.removeTag(kModalTag);
    // The imperative ones: showDialog, showModalBottomSheet, showMenu, a
    // dropdown — on top of the stack or on the root navigator. The page based
    // popups belong to the controller and are already gone from its stack.
    final navigators = <NavigatorState>{
      ?controller?.navigator,
      ?Navigator.maybeOf(_context, rootNavigator: true),
    };
    for (final navigator in navigators) {
      navigator.popUntil(
        (route) => route is! PopupRoute || route.settings is Page,
      );
    }
    // The context menu of a text field.
    ContextMenuController.removeAny();
  }
}

extension AppNavigationContext on BuildContext {
  AppNavigation get nav => AppNavigation(this);
}
context.nav.push(const ProductRoute(42));
context.nav.change((stack) => stack.withoutType<ProductRoute>());
context.nav.popModals();

removeTag(kModalTag) alone closes only the declarative popups: a showDialog or a menu is invisible to the controller, so a helper like popModals is what closes everything — for example before following a push notification. Mind the context: showDialog opens the dialog on the root navigator of the application, above every NavigationView, so there is no controller in the context of its builder. Call the helper with the context of the screen that opened the dialog.

A deep link is just another way to rewrite the stack: parse it into routes, put them into the controller, and the guards decide what is actually shown — a signed out user still lands on the sign in screen.

The link the application has been launched with is the initial route name of the engine: the path of the address bar on the web, and the path of the link on Android and iOS once deep linking is enabled for the platform (flutter_deeplinking_enabled). The links that arrive later are delivered to the WidgetsBindingObservers:

class _AppState extends State<App> with WidgetsBindingObserver {
  late final NavigationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = NavigationController(<NavigationRoute>[Routes.home]);
    // The link the application has been launched with.
    final name = WidgetsBinding.instance.platformDispatcher.defaultRouteName;
    if (name != Navigator.defaultRouteName) _open(Uri.parse(name));
    // The links that arrive while the application is running.
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _controller.dispose();
    super.dispose();
  }

  @override
  Future<bool> didPushRouteInformation(RouteInformation information) =>
      SynchronousFuture<bool>(_open(information.uri));

  /// Returns `false` for an unknown link, so the platform handles it.
  bool _open(Uri uri) {
    final stack = parseDeepLink(uri);
    if (stack == null) return false;
    _controller.stack = <NavigationRoute>[Routes.home, ...stack];
    return true;
  }

  @override
  Widget build(BuildContext context) => MaterialApp(
    // Without these two callbacks `MaterialApp` tries to open the link as a
    // named route itself and reports that there is no such route.
    onGenerateInitialRoutes: (_) => <Route<void>>[
      MaterialPageRoute<void>(
        builder: (_) => NavigationView(controller: _controller),
      ),
    ],
    onGenerateRoute: (_) => null,
  );
}

Squid does not dictate the format of the links — parseDeepLink is a plain function, and there are three common ways to write it.

A switch over the path segments. The most readable while the links are few: every shape of a link is one line, the arguments are extracted and validated in place.

NavigationStack? parseDeepLink(Uri uri) => switch (uri.pathSegments) {
  [] => <NavigationRoute>[],
  ['settings'] => <NavigationRoute>[Routes.settings],
  ['product', final id] when int.tryParse(id) != null =>
    <NavigationRoute>[ProductRoute(int.parse(id))],
  ['product', final id, 'reviews'] when int.tryParse(id) != null =>
    <NavigationRoute>[ProductRoute(int.parse(id)), ReviewsRoute(int.parse(id))],
  _ => null,
};

A table of regular expressions. Handy when the links are many, come from the backend or from marketing, and are easier to maintain as data:

final List<(RegExp, NavigationStack Function(RegExpMatch match))> table = [
  (
    RegExp(r'^/product/(?<id>\d+)/?$'),
    (match) => <NavigationRoute>[
      ProductRoute(int.parse(match.namedGroup('id')!)),
    ],
  ),
  (RegExp(r'^/settings/?$'), (_) => <NavigationRoute>[Routes.settings]),
];

NavigationStack? parseDeepLink(Uri uri) {
  for (final (pattern, build) in table) {
    final match = pattern.firstMatch(uri.path);
    if (match != null) return build(match);
  }
  return null;
}

Every segment is a route. /product-3/product-4/settings opens the product 3, the product 4 above it and the settings on top. Any combination of the screens can be linked without declaring it in advance, and the address mirrors the stack — exactly what the web expects. A nonsensical combination is fixed by the guards, the same way a mistake in the code would be.

NavigationStack parseDeepLink(Uri uri) => <NavigationRoute>[
  for (final segment in uri.pathSegments)
    ?switch (segment.split('-')) {
      ['product', final id] when int.tryParse(id) != null =>
        ProductRoute(int.parse(id)),
      ['settings'] => Routes.settings,
      _ => null, // an unknown segment is skipped
    },
];

The example implements all three for an application with tabs, where the first segment selects the tab.

Testing #

Most of the navigation is tested without a widget tree at all:

test('a signed out user cannot leave the sign in screen', () {
  final controller = NavigationController(
    <NavigationRoute>[Routes.home],
    guards: <NavigationGuard>[
      GateGuard(
        isOpen: () => false,
        tag: 'unauthenticated',
        closed: () => <NavigationRoute>[Routes.signIn],
        opened: () => <NavigationRoute>[Routes.home],
      ),
    ],
  );

  controller.push(Routes.settings);

  expect(controller.stack, equals(<NavigationRoute>[Routes.signIn]));
});

A guard is a pure function, so it can also be called directly:

expect(
  const PriorityGuard()(controller, <NavigationRoute>[dialog, home]),
  equals(<NavigationRoute>[home, dialog]),
);

Coming from octopus #

octopus is the ancestor of this package and shares its philosophy — navigation as a state you mutate. Squid keeps the ideas and drops the machinery:

octopus squid
A tree of nodes with names and arguments A flat List<NavigationRoute>
Nested navigation inside the state tree One controller per navigator
Routes are matched by name at runtime Routes are the objects themselves
The state is serialized into the URL No URL layer, a deep link is a stack
Asynchronous guards, transactions, queue Synchronous pure guards
A singleton router and a RouterConfig Plain objects you create and own

Choose octopus when the navigation tree must survive in the address bar and the browser history. Choose squid when you want the declarative model without the serialization layer — a mobile application, a nested navigator, a tabbed shell.

Design notes #

  • An empty stack is impossible. A change that would empty it is rejected.
  • Keys are unique. Adding a route whose key is already present moves that route to the top instead of breaking the Navigator.
  • Nothing is notified twice. A change that produces an equal stack is a no-op, so guards can be idempotent without fear.
  • Reentrancy is safe. A change requested from a guard, an observer or a listener is applied right after the current one instead of corrupting it.
  • The controller outlives the widgets. It can be created, mutated and inspected before the first frame and after the last one. The widget tree is an optional input of the guards, never a requirement.
  • Guards must be idempotent. They run on every change, on every revalidation and every time an inherited widget they read changes, so guard(guard(stack)) has to equal guard(stack).

Example #

The example is a small shop with three tabs, an authentication gate, a dialog that returns a value, a bottom sheet, cross tab navigation, a stack limit, deep links and an application specific context.nav extension type with popModals.

Maintainers #

Funding #

If you want to support the development of our library, there are several ways you can do it:

We appreciate any form of support, whether it's a financial donation or just a star on GitHub. It helps us to continue developing and improving our library. Thank you for your support!

MIT License #

3
likes
0
points
--
downloads

Documentation

API reference

Publisher

verified publisherplugfox.dev

Weekly Downloads

A simple, reliable and extensible declarative navigator for Flutter. The whole navigation is a plain list of routes you can rewrite at any moment.

Repository (GitHub)
View/report issues

Topics

#navigation #navigator #router #declarative #guards

Funding

Consider supporting this project:

www.buymeacoffee.com
www.patreon.com
boosty.to

License

unknown (license)

Dependencies

flutter

More

Packages that depend on squid