squid 0.1.1
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 #
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 · Installation · Quick start
- Routes — with parameters, dialogs and sheets
- Navigating · Guards · Observers · Tabs
- The back button · Helpers of your application · Deep links · Testing
- Coming from octopus · Design notes
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
enumfor the simple ones, asealed classfor the ones with parameters. Exhaustiveswitch, real types, no strings. - 🪟 Dialogs and bottom sheets are routes too. No
showDialogrunning 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.
Navigating #
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:
- 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.
- Returning
controller.stackcancels the change. The controller has not committed anything yet when the guards run, so the previous stack is always available. - A throwing guard is skipped, reported to
FlutterErrorand 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.
Deep links #
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 equalguard(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!