nav_stack
A simple but powerful path-based routing system, based on MaterialApp.router (Nav 2.0). It has browser / deeplink support and maintains a history stack as new routes are added. It also provides a flexible imperative API for changing the path and modifying the history stack.
đ¨ Installation
dependencies:
nav_stack: ^0.0.1
â Import
import 'package:nav_stack/nav_stack.dart';
đšī¸ Basic Usage
Hello NavStack
To get started pass a NavStackParser()
and NavStackDelegate()
to MaterialApp.router
, and declare all of your routes inside of onGenerateStack
callback.
runApp(
MaterialApp.router(
routeInformationParser: NavStackParser(),
routerDelegate: NavStackDelegate(
// Declare your full tree of page routes,
// PathStack is a component that will automatically figure out what to show for the current navigation path
onGenerateStack: (context, nav) => PathStack(
routes: {
["/"]: HomeScreen().toStackRoute(),
["/messages"]: MessagesScreen().toStackRoute(),
["/profile"]: ProfileScreen().toStackRoute(),
},
),
));}
}));
This might not look like much, but there is a lot going on here.
- This is fully bound to the browser path,
- It will also receive deep-link start up values on any platform,
- It provides a
nav
controller which you can use to easily change the global path at any time,- You can look this up from anywhere with
NavStack.of(context)
- You can look this up from anywhere with
- All routes are persistent, maintaining their state as you navigate between them (optional)
In it's basic form this gives you the class behavior of a routing table, built using MaterialApp.onGenerateRoute
.
How it works
NavStack
is really a combination of 2 discrete components.
- There's
NavStack
library which talks toMaterialApp.router
and provides an API to read and write the global navigation path. - Then there is
PathStack
which parses and renders the routes, according to the current navigation path.
PathStack
is like an IndexedStack
that uses strings instead of integers for it's keys:
- It supports a number of extra features like scaffold nesting, custom transitions, relative paths and param arguments.
- You can nest multiple
PathStack
s within eachother to form complex routing tables easily - Like
IndexedStack
the children of aPathStack
can be persistent - Under the hood, it is actually just an
IndexedStack
and aMap<String, Widget>
For more documentation on PathStack
see here: https://pub.dev/packages/path_stack
Nested Routes and Stacks
To wrap a custom Scaffold or Menu around all child routes use the scaffoldBuilder
. One common application is an app with a persistent tab menu:
onGenerateStack: (_, __) => PathStack(
// Use scaffold builder to wrap all our pages in a stateful tab-menu
scaffoldBuilder: (_, stack) => _TabScaffold(["/home", "/profile"], child: stack),
routes: {
["/home"]: LoginScreen().toStackRoute(),
["/profile"]: ProfileScreen().toStackRoute(),
}));
If you'd like to wrap a Scaffold around a specific set of pages, just create another PathStack
!
Here we wrap an inner tab-menu around all routes in the /settings/
section of our app by just combining 2 stacks:
onGenerateStack: (_, __) {
return PathStack( // OuterScaffold
scaffoldBuilder: (_, stack) => OuterTabScaffold(stack),
routes: {
["/login", "/"]: LoginScreen().toStackRoute(),
["/settings/"]: PathStack( // Inner Scaffold
scaffoldBuilder: (_, stack) => InnerTabScaffold(stack),
routes: {
// This will match "/settings/profile". By default all routes are relative to their parent PathStack.
["profile"]: ProfileScreen().toStackRoute(),
["alerts"]: AlertsScreen().toStackRoute(),
},
).toStackRoute(),
},);},)
When you change routes inside the /settings
section, both Scaffolds will stay in place, and only the inner region will animate! Scaffolds are fully stateful as well, so you can have animations and other flourishes when routes change.
Path Parsing Rules:
There are a number of rules that determine how paths are routed:
- Routes with no trailing slash must an exact match:
- eg,
/details
matches only/details
not/details/
,/details/12
or/details/?id=12
- a special case is made for
/
which is always an exact match
- eg,
- Routes with a trailing slash, will accept a suffix,
- eg,
/details/
matches any of/details/
,/details/12
,/details/id=12&foo=99
etc - this allows endless levels of nesting and relative routes
- eg,
- If route has multiple paths, only the first one will be considered for the suffix check
- eg,
["/details", "/details/"]
requires exact match on either path - eg,
["/details/", "/details"]
allows suffix on either path
- eg,
These take some time to understand but when combined they allow you to express most tree structures you can think of.
Defining path and query-string arguments
Both path-based (/billing/88/99
) or query-string (/billing/?foo=88&bar=99
) args are supported.
A route that consumes path-based args looks like:
// /billing/88/99
["billing/:foo/:bar"]: StackRouteBuilder(
builder: (_, args) => BillingPage(foo: args["foo"], bar: args["bar"])),
One that uses query-string args looks like:
// /billing/?foo=88&bar=99
["billing/"]: StackRouteBuilder(
builder: (_, args) => BillingPage(id: "${args["foo"]}_${args["bar"]}")),
If you would like to access the args from within your view, and parse them there, you can just do:
NavStack.of(context).args;
For more information on how paths are parsed check out https://pub.dev/packages/path_to_regexp. To play with different routing schemes, you can use this demo: https://path-to-regexp.web.app/
toStackRoute()
vs StackRouteBuilder
One of the requirements of PathStack
is that each page Widget is wrapped in a StackRouteBuilder()
. Because this can be a little hard to read, we have added a .toStackRoute()
extension method on all Widgets. The only difference between the two, is that the full StackRouteBuilder
allows you to inject args directly into your view using it's builder
method.
If your view does not require path args, consider the extension methods as they tend to be more readable:
// These calls are identical
["/login"]: LoginScreen().toStackRoute(),
VS
["/login"]: StackRouteBuilder(builder: (_, __) => LoginScreen()),
What about Navigator.of()?
Importantly, you can still make full use of the old Navigator.push()
, showDialog
, showBottomSheet
APIs, just be aware that none of these routes will be reflected in the navigation path. This can be quite handy for user-flows that do not necessarily need to be bound to browser history. Additionally Overlay is still there, and acts exactly as you'd expect.
The main difference now is that Navigator should be mainly be used for things that go overtop of the app, and not changing pages within the app itself.
Important: The entire NavStack
exists within a single PageRoute
. This means that calls to Navigator.of(context).pop()
from within the NavStack
children will be ignored. However, you can still use .pop()
them from within Dialogs, BottomSheets or pages added with Navigator.push()
.
đš Advanced Usage
In addition to basic nesting and routing, NavStack
supports advanced features including Aliases, Regular Expressions and Route Guards.
Regular Expressions
One powerful aspect of the path-base args is you can append Regular Expressions to the match.
- eg, a route of
/user/:foo(\d+)
will match '/user/12' but not '/user/alice' - Don't worry if you don't know Regular Expressions, they are optional, and best used for advanced use cases
For more details on this parsing, check out the PathToRegExp
docs:
https://pub.dev/packages/path_to_regexp
Aliases
Each route entry can have multiple paths allowing it to match any of them. For example, we can setup a route to match both /home
and /
:
["/home", "/"]: LoginScreen().toStackRoute(),
Or a route that accepts optional named params:
// matches both "/messages/" and "messages/99"
["/messages/", "/messages/:messageId"]: StackRouteBuilder(
builder: (_, args) => MessageView(args["messageId"] ?? "")
Route Guards
Guards allow you to intercept a navigation event on a per-route basis. Used primarily to prevent deep-links into unauthorized app sections. Commonly you might wrap all your main pages with an authGuard
, and leave only the LoginView
unauthorized, or you may have an authorized section that is guarded, like /admin/
.
To do this you can use the StackRouteBuilder.onBeforeEnter
callback to run your own custom logic, and decide whether to block the change. For example, this guard will protect the AdminPage
and redirect to LoginScreen
:
// You can use either the `buildStackRoute` or `StackRouteBuilder` to add guards
["/admin"]: AdminPanel().buildStackRoute(
onBeforeEnter: (_, __) => guardAuthSection()),
...
bool guardAuthSection(String newRoute) {
if (!appModel.isLoggedIn) NavStack.of(context).redirect("/login");
return appModel.isLoggedIn;// If we return false, the original route will not be entered.
}
Since guards are just functions, you can easily re-use them across routes, and they can also be applied to entire sections by nesting a PathStack
component.
Putting it Together
Here's a a more complete example showing nested stacks, and an entire section that requires the user to be logged in. Otherwise they are redirected to /login
:
bool isLoggedIn = false;
...
onGenerateStack: (context, controller) {
return PathStack(
scaffoldBuilder: (_, stack) => _MyScaffold(stack),
routes: {
["/login", "/"]: LoginScreen().toStackRoute(),
["/in/"]: PathStack(
routes: {
["profile/:profileId"]:
StackRouteBuilder(builder: (_, args) => ProfileScreen(profileId: args["profileId"] ?? "")),
["settings"]: SettingsScreen().toStackRoute(),
},
).toStackRoute(onBeforeEnter: (_) {
if (!isLoggedIn) controller.redirect("/login");
return isLoggedIn; // If we return false, the route will not be entered.
}),
},
);
},
...
void handleLoginPressed() => NavStack.of(context).path = "/login";
void showProfile() => NavStack.of(context).path = "/in/profile/23"; // Blocked
void showSettings() => NavStack.of(context).path = "/in/settings"; // Blocked
Note: String literals ("/home"
) are used here for brevity and clarity. In real usage, it is recommended you give each page it's own path property like HomePage.path
or LoginScreen.path
. This makes it much easier to construct and share links from other sections in your app: controller.path = "${SettingsPage.path}{ProfilePage.path}$profileId"
There are many other options you can provide to the PathStack
, including unknownPathBuilder
, transitionBuilder
and, basePath
. For an exhaustive list, check out this example:
- https://github.com/gskinnerTeam/flutter_path_stack/blob/master/example/lib/full_api_example.dart
- https://github.com/gskinnerTeam/flutter_path_stack/blob/master/example/lib/simple_tab_example.dart
- https://github.com/gskinnerTeam/flutter_path_stack/blob/master/example/lib/advanced_tab_example.dart
đ Bugs/Requests
If you encounter any problems please open an issue. If you feel the library is missing a feature, please raise a ticket on Github and we'll look into it. Pull request are welcome.
đ License
MIT License