zenrouter_core
Platform-independent routing framework for building custom navigation systems.
What is zenrouter_core?
zenrouter_core provides the core abstractions for implementing arbitrary routing structures. It defines the relationship between routes, navigation stacks, and their rendering—but leaves the actual rendering implementation to you.
┌──────────────────────────────────────────────────────────────────┐
│ Your Implementation │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ RouteTarget │───▶│ StackPath │───▶│ Component Render │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │ │ ▲ │
│ │ │ │ │
│ │ manages │ notifies │ │
│ └────────────────────┴─────────────────────┘ │
│ │
│ RouteTarget: WHAT to navigate (destination, data, identity) │
│ StackPath: HOW to navigate (push, pop, stack operations) │
│ Component: HOW to display (platform-specific rendering) │
│ │
└──────────────────────────────────────────────────────────────────┘
You implement: Routes, StackPath subclasses, and Component Render
zenrouter_core provides: RouteTarget, CoordinatorCore, all mixins, and navigation primitives
Installation
dependencies:
zenrouter_core: ^2.0.0
Architecture
The Triangle Relationship
| Component | Role | You Implement |
|---|---|---|
| RouteTarget | WHAT - Defines the navigation destination | Your route classes |
| StackPath | HOW - Manages route stack operations | Optional custom paths |
| Component Render | DISPLAY - Renders routes to screen | Required (Flutter widgets, DOM, etc.) |
The flow:
- Push a RouteTarget onto a StackPath
- StackPath notifies listeners of stack changes
- Component Render listens and rebuilds based on current stack
Core Components
RouteTarget
Role: Base class for all navigation destinations.
Every screen, dialog, or navigable component extends RouteTarget. It provides:
- Route identity and equality (via
props) - Navigation lifecycle hooks (
onDidPop,onDiscard,onUpdate) - Stack path binding for coordinator access
class ProfileRoute extends RouteTarget {
final int userId;
ProfileRoute({required this.userId});
@override
List<Object?> get props => [userId];
}
Lifecycle: Creation → Redirect → Path Binding → Build → Active → Pop Request → Guard Check → Pop Completion → Cleanup
CoordinatorCore
Role: Central hub managing navigation state and operations.
Provides:
- Navigation methods:
push,pop,replace,navigate,recover - Deep link handling via
parseRouteFromUri - Layout hierarchy resolution
- Listener notifications for UI rebuilds
class AppCoordinator extends CoordinatorCore<AppRoute> {
@override
StackPath<AppRoute> get root =>
NavigationPath(key: const PathKey('root'));
@override
Future<AppRoute?> parseRouteFromUri(Uri uri) async {
// Convert URI to route
}
}
Navigation Methods:
| Method | Behavior |
|---|---|
push(route) |
Adds route to stack |
pop(result) |
Removes top route |
replace(route) |
Clears stack, sets single route |
navigate(route) |
Smart navigation - pops to existing or pushes new |
recover(route) |
Deep link handling with RouteDeepLink strategy |
StackPath
Role: Container managing a stack of RouteTargets.
Provides:
- Route storage and access (
stack,activeRoute) - Path key for layout builder lookup
- Listener notifications for changes
// Access stack state
path.stack; // Unmodifiable list of all routes
path.activeRoute; // Top of stack (current route)
path.pathKey; // PathKey identifier for builder lookup
StackMutatable mixin adds:
push(route)- Add to toppop(result)- Remove top (respects guards)replace(route)- Replace currentnavigate(route)- Browser-style navigation
Route Mixins
Mixins add capabilities to RouteTarget.
RouteUri
Role: Provides URI-based identity for URL synchronization.
class AppRoute extends RouteUri {
final int userId;
@override
Uri toUri() => Uri.parse('/profile/$userId');
@override
List<Object?> get props => [userId];
}
Combines RouteIdentity<Uri> + RouteLayoutChild.
RouteDeepLink
Role: Configures deep link handling behavior.
class AppRoute extends RouteUri with RouteDeepLink {
@override
Uri toUri() => Uri.parse('/profile/$userId');
@override
DeeplinkStrategy get deeplinkStrategy => DeeplinkStrategy.navigate;
@override
Future<void> deeplinkHandler(CoordinatorCore coordinator, Uri uri) async {
// Custom handling
}
}
Strategies:
| Strategy | Behavior |
|---|---|
replace |
Replace current stack (default) |
navigate |
Pop to existing or push new |
push |
Always push new |
custom |
Use custom handler |
RouteGuard
Role: Blocks pop operations based on conditions.
class FormRoute extends RouteTarget with RouteGuard {
@override
FutureOr<bool> popGuard() async {
if (hasUnsavedChanges) {
return await _showDiscardDialog();
}
return true;
}
// Variant with coordinator access
@override
FutureOr<bool> popGuardWith(CoordinatorCore coordinator) {
return popGuard();
}
}
Called during: StackMutatable.pop(), CoordinatorCore.tryPop(), browser back button.
RouteIdentity
Role: Provides unique identifier for route matching.
// URI-based (common)
class AppRoute extends RouteTarget with RouteIdentity<Uri> {
@override
Uri get identifier => Uri.parse('/profile/$userId');
}
// String-based
class AppRoute extends RouteTarget with RouteIdentity<String> {
@override
String get identifier => 'profile_$userId';
}
Nested Routing with RouteLayout
Role: Enable nested layout hierarchies (tab navigation, shell routes).
Real Example from zenrouter:
┌────────────────────────────────────────────────────────────────┐
│ AppCoordinator │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ paths: [homeStack, tabIndexed, settingsStack, ...] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
│ │ homeStack │ │ tabIndexed │ │settingsStack│ │
│ │ (NavPath) │ │(IdxStack) │ │ (NavPath) │ │
│ └────────────┘ └────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ bindLayout │ bindLayout │ bindLayout │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │HomeLayout │ │TabBarLayout│ │SettingsLayout│ │
│ │ │ │ │ │ │ │
│ │ resolvePath│ │ resolvePath│ │ resolvePath. │ │
│ │ ───────► │ │ ───────► │ │ ───────► │ │
│ │ homeStack │ │ tabIndexed │ │settingsStack.│ │
│ └────────────┘ └────────────┘ └──────────────┘ │
│ │ │
│ │ Route.layout = HomeLayout │
│ ▼ │
│ ┌────────────┐ │
│ │ FeedDetail │ ◄── belongs to HomeLayout │
│ └────────────┘ │
└────────────────────────────────────────────────────────────────┘
Key Concepts:
| Concept | Type | Description |
|---|---|---|
RouteLayoutParent.layoutKey |
Object |
Lookup key for finding layout constructor (not forced to be Type) |
RouteLayoutChild.parentLayoutKey |
Object? |
The key that identifies which parent layout this route belongs to |
RouteLayout.resolvePath() |
StackPath |
Returns the StackPath this layout manages |
StackPath.bindLayout() |
void | Binds a layout constructor to a path |
Note: In zenrouter (Flutter), RouteLayout provides a default layoutKey returning runtimeType, and RouteUnique adds a convenience .layout property (Type). But in zenrouter_core, layoutKey is just Object - you can use any object as key.
Implementation:
// 1. Define base route
abstract class AppRoute extends RouteTarget with RouteUnique {}
// 2. Define layout route (shell with nested navigation)
// Note: layoutKey can be any Object, not just Type
class HomeLayout extends AppRoute with RouteLayout<AppRoute> {
// layoutKey defaults to runtimeType, or override:
// @override
// Object get layoutKey => 'home'; // Use String key instead
// Return the StackPath this layout manages
@override
NavigationPath<AppRoute> resolvePath(AppCoordinator coordinator) =>
coordinator.homeStack;
// Build the layout UI
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
// RouteLayout.buildPath() internally gets the builder
body: buildPath(coordinator),
);
}
}
// 3. Child routes specify their layout via .layout property (zenrouter convenience)
class FeedDetail extends AppRoute {
@override
Type get layout => HomeLayout; // Belongs to HomeLayout
@override
Uri toUri() => Uri.parse('/home/feed/$id');
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Feed Detail')),
body: Center(child: Text('Feed $id')),
);
}
}
// 4. Tab layout (uses IndexedStackPath)
class TabBarLayout extends AppRoute with RouteLayout<AppRoute> {
@override
IndexedStackPath<AppRoute> resolvePath(AppCoordinator coordinator) =>
coordinator.tabIndexed;
@override
Widget build(AppCoordinator coordinator, BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(child: buildPath(coordinator)), // IndexedStack
// Tab bar
BottomNavigationBar(...),
],
),
);
}
}
// 5. Coordinator creates paths and binds layouts
class AppCoordinator extends Coordinator<AppRoute> {
late final NavigationPath<AppRoute> homeStack = NavigationPath.createWith(
label: 'home',
coordinator: this,
)..bindLayout(HomeLayout.new); // Bind layout to path
late final IndexedStackPath<AppRoute> tabIndexed =
IndexedStackPath.createWith(coordinator: this, label: 'tabs', [
FeedTabLayout(),
ProfileTab(),
SettingsTab(),
])..bindLayout(TabBarLayout.new);
@override
List<StackPath> get paths => [root, homeStack, tabIndexed, settingsStack];
}
How Navigation Works:
- Push
FeedDetail(layout: HomeLayout) - Coordinator checks
route.layout→ finds HomeLayout - If HomeLayout not active, activates it on
homeStack - Pushes FeedDetail onto
homeStack(which HomeLayout resolves to) - When rendering HomeLayout:
buildPath(coordinator)→getLayoutBuilder(path.pathKey)
RouteRedirect
Role: Transforms routes before navigation.
class SplashRoute extends RouteTarget with RouteRedirect<RouteTarget> {
@override
FutureOr<RouteTarget> redirect() async {
if (await auth.isLoggedIn) {
return HomeRoute();
}
return LoginRoute();
}
// Variant with coordinator access
@override
FutureOr<RouteTarget?> redirectWith(CoordinatorCore coordinator) {
return redirect();
}
}
Advanced Features
CoordinatorModular
Role: Compose multiple route modules into one coordinator.
class AppCoordinator extends CoordinatorModular<AppRoute> {
@override
Set<RouteModule<AppRoute>> defineModules() => {
AuthModule(this),
ShopModule(this),
};
@override
AppRoute notFoundRoute(Uri uri) => NotFoundRoute();
}
RouteModule responsibilities:
parseRouteFromUri- Handle subset of URIspaths- Nested navigation pathsdefineLayout- Layout constructorsdefineConverter- State restoration
Internal Utilities
Myers Diff Algorithm - For declarative navigation:
import 'package:zenrouter_core/src/internal/diff.dart';
final oldStack = [routeA, routeB, routeC];
final newStack = [routeA, routeD, routeC];
final ops = myersDiff(oldStack, newStack);
// Result: [Keep(0,0), Delete(1), Insert(routeD, 1), Keep(2,2)]
applyDiff(path, ops);
Building a Platform Integration
zenrouter_core is renderer-agnostic. To use with a platform:
-
Create your routes extending RouteTarget with appropriate mixins
-
Create Coordinator extending CoordinatorCore
-
Implement component render that:
- Listens to Coordinator/StackPath changes
- Builds widgets based on current stack
- Handles back button, deep links
Export Reference
// Core
export 'src/coordinator/base.dart'; // CoordinatorCore
export 'src/coordinator/modular.dart'; // CoordinatorModular, RouteModule
export 'src/path/base.dart'; // StackPath, PathKey, StackMutatable
export 'src/path/navigatable.dart'; // StackNavigatable, NavigationPath
// Mixins
export 'src/mixin/target.dart'; // RouteTarget
export 'src/mixin/uri.dart'; // RouteUri
export 'src/mixin/deeplink.dart'; // RouteDeepLink, DeeplinkStrategy
export 'src/mixin/guard.dart'; // RouteGuard
export 'src/mixin/identity.dart'; // RouteIdentity
export 'src/mixin/layout.dart'; // RouteLayoutParent, RouteLayoutChild
export 'src/mixin/redirect.dart'; // RouteRedirect
export 'src/mixin/redirect_rule.dart'; // RedirectRule
// Internal
export 'src/internal/diff.dart'; // myersDiff, DiffOp, applyDiff
export 'src/internal/equatable.dart'; // Equatable
export 'src/internal/reactive.dart'; // ListenableObject