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)
  • 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 to MaterialApp.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 PathStacks within eachother to form complex routing tables easily
  • Like IndexedStack the children of a PathStack can be persistent
  • Under the hood, it is actually just an IndexedStack and a Map<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
  • 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
  • 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

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:

🐞 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