The traditional imperative navigation model in Flutter (Navigator 1.0) works sufficiently for simple mobile applications where the stack is linear and ephemeral. However, as applications scale to support deep linking, web URLs, and complex state dependencies, the "fire-and-forget" nature of Navigator.push and Navigator.pop becomes a significant technical debt. The core issue is the desynchronization between the application's business logic (state) and the visual navigation stack. Navigator 2.0 (Router API) addresses this by inverting control: the navigation stack becomes a purely declarative function of the application state.
1. The Architectural Shift: Declarative Routing
In the imperative model, navigation logic is scattered across UI widgets. A button press triggers a push, creating a side effect where the navigation stack evolves independently of the app's data model. This makes scenarios like "restore the app to a specific state from a URL" notoriously difficult because the stack must be imperatively reconstructed step-by-step.
Navigator 2.0 aligns with the core Flutter principle: UI = f(State). Here, the navigation stack is simply another UI component derived from state. You do not "push" a screen; you update the state (e.g., selectedBookId = 42), and the RouterDelegate rebuilds the entire Navigator widget with the correct list of pages.
This approach solves three critical engineering challenges:
| Challenge | Imperative (Nav 1.0) | Declarative (Nav 2.0) |
|---|---|---|
| Deep Linking | Requires manual parsing and stack reconstruction. | Native support via RouteInformationParser. |
| Web URLs | URL often desyncs from the actual UI. | URL is a direct reflection of the app state. |
| Testing | Requires mocking BuildContext and navigation callbacks. |
Unit testable by verifying state transformations. |
2. Core Components and Implementation
To implement a robust navigation system, we must orchestrate four key components: Page, Router, RouteInformationParser, and RouterDelegate. Below is an architectural breakdown and implementation strategy.
2.1 Typed State Definition
Before touching widgets, define the navigation state. Avoid using raw strings or generic objects. A sealed class or a strongly typed configuration object ensures compile-time safety.
class AppRoutePath {
final int? id;
final bool isUnknown;
AppRoutePath.home() : id = null, isUnknown = false;
AppRoutePath.details(this.id) : isUnknown = false;
AppRoutePath.unknown() : id = null, isUnknown = true;
bool get isHomePage => id == null && !isUnknown;
bool get isDetailsPage => id != null;
}
2.2 The RouterDelegate
The RouterDelegate is the single source of truth. It manages the app state and builds the Navigator. It listens to state changes and rebuilds the page stack accordingly.
class AppRouterDelegate extends RouterDelegate<AppRoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {
@override
final GlobalKey<NavigatorState> navigatorKey;
int? _selectedBookId;
bool _show404 = false;
AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
// 1. Expose current state to the Router
@override
AppRoutePath get currentConfiguration {
if (_show404) return AppRoutePath.unknown();
if (_selectedBookId != null) return AppRoutePath.details(_selectedBookId);
return AppRoutePath.home();
}
// 2. Build the Navigator stack based on state
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage(
key: ValueKey('BookListPage'),
child: BookListScreen(
onBookTapped: (id) {
_selectedBookId = id;
notifyListeners(); // Triggers rebuild
},
),
),
if (_show404)
MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
else if (_selectedBookId != null)
MaterialPage(
key: ValueKey('BookDetailsPage_$_selectedBookId'),
child: BookDetailsScreen(id: _selectedBookId!),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) return false;
// Handle the imperative pop (e.g., back button) by updating state
if (_selectedBookId != null) {
_selectedBookId = null;
}
_show404 = false;
notifyListeners();
return true;
},
);
}
// 3. Update state from external source (e.g., URL change)
@override
Future<void> setNewRoutePath(AppRoutePath path) async {
if (path.isUnknown) {
_show404 = true;
_selectedBookId = null;
} else if (path.isDetailsPage) {
_selectedBookId = path.id;
_show404 = false;
} else {
_selectedBookId = null;
_show404 = false;
}
}
}
onPopPage callback is critical. If you fail to update your internal state variables (like setting _selectedBookId = null) when a route is popped, the visual stack and your state variables will drift apart, causing erratic behavior on subsequent navigations.
3. URL and Deep Link Handling
The RouteInformationParser bridges the gap between the platform (OS) and the RouterDelegate. It converts the platform's routing information (URL) into your typed configuration (AppRoutePath) and vice versa.
class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
// URL -> State (Deep Linking)
@override
Future<AppRoutePath> parseRouteInformation(
RouteInformation routeInformation) async {
final uri = Uri.parse(routeInformation.location ?? '');
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'book') {
final id = int.tryParse(uri.pathSegments[1]);
if (id != null) return AppRoutePath.details(id);
}
return uri.pathSegments.isEmpty
? AppRoutePath.home()
: AppRoutePath.unknown();
}
// State -> URL (Browser Address Bar)
@override
RouteInformation? restoreRouteInformation(AppRoutePath path) {
if (path.isUnknown) return RouteInformation(location: '/404');
if (path.isHomePage) return RouteInformation(location: '/');
if (path.isDetailsPage) return RouteInformation(location: '/book/${path.id}');
return null;
}
}
4. Determining the Current Page
A common friction point for developers transitioning to Navigator 2.0 is the question: "How do I check which page is currently visible?" In the imperative world, one might check the widget tree or maintain a separate history list. In the declarative world, you query the source of truth.
Strategy: Querying the Delegate
Since the RouterDelegate holds the state that generates the pages, asking the delegate gives you the definitive answer.
// Inside any widget
final delegate = Router.of(context).routerDelegate as AppRouterDelegate;
final currentConfig = delegate.currentConfiguration;
if (currentConfig.isDetailsPage) {
// Logic specific to details page
analytics.logEvent('view_details', {'id': currentConfig.id});
}
For scenarios requiring event-based tracking (e.g., "Screen A pushed", "Screen B popped"), standard RouteObserver patterns still apply and can be injected into the `Navigator` within the `build` method of your delegate.
Conclusion: Trade-offs and Adoption
Adopting Navigator 2.0 introduces initial complexity through boilerplate code (Delegate, Parser, Typed State). However, for enterprise-grade applications, the trade-off yields significant long-term benefits in stability and platform interoperability. By decoupling the "intent" (State) from the "execution" (Navigator Stack), we gain a predictable system where deep links, web history, and complex nested navigation flows are handled with mathematical precision rather than imperative patches.
Post a Comment