Friday, July 14, 2023

Declarative Navigation and State in Flutter 2.0

The evolution of Flutter brought with it Navigator 2.0, a significant paradigm shift in how developers approach routing and application state. Moving away from the simple, imperative commands of its predecessor, Navigator 2.0 introduces a declarative, state-driven system. This new API, while initially more complex, unlocks powerful capabilities for handling deep linking, browser history, and complex navigation scenarios with remarkable clarity and testability. The fundamental question for many developers transitioning is no longer just "how do I push a new screen?" but rather "how does my application state define what screen is visible?" Understanding this shift is key to mastering modern Flutter development and reliably determining the current page or, more accurately, the current navigation state of your application.

The Paradigm Shift: From Imperative Commands to Declarative State

To fully appreciate the design of Navigator 2.0, it's essential to first understand the limitations of the original Navigator API (now referred to as Navigator 1.0). The traditional approach is imperative. You issue direct commands to the navigation system.

  • Navigator.push(context, MaterialPageRoute(builder: (_) => DetailsScreen()));
  • Navigator.pop(context);
  • Navigator.pushNamed(context, '/details');

This is akin to giving turn-by-turn directions. "Go to the details screen now." "Go back to the previous screen." While simple and effective for basic applications, this model begins to show its cracks in more complex scenarios:

  • State and UI Desynchronization: The navigation stack becomes a separate, hidden source of state. Your application's core state (e.g., which user is logged in, which item is selected) might not be perfectly reflected in the stack of screens. Reconstructing the navigation stack from a saved state or a deep link URL is notoriously difficult.
  • Web and Desktop Challenges: On the web, users expect the browser's address bar (URL) and back/forward buttons to work seamlessly. An imperative system makes this nearly impossible to manage correctly because pushing a screen doesn't inherently update the URL, and a URL change doesn't automatically build the correct screen stack.
  • Testability: It's hard to unit test a navigation flow that relies heavily on BuildContext and static Navigator methods.

Navigator 2.0 flips this model on its head by being declarative. Instead of telling the Navigator what to do, you describe what the navigation stack should look like based on your current application state. You provide a list of pages, and Flutter's framework figures out the most efficient way to transition from the old list to the new one—adding, removing, or replacing screens as needed.

The analogy here is moving from giving driving directions to simply providing a destination address to a GPS. You declare the desired end state, and the system handles the implementation. This approach makes your UI a direct function of your state (UI = f(state)), a core principle in modern UI development. When the state changes, the navigation stack rebuilds automatically, ensuring they are always in sync.

The Core Architecture of Navigator 2.0

The declarative navigation system is built upon a set of cooperating classes that work together to translate between your application's state, the platform's route information (like a URL), and the stack of widgets on the screen. Understanding each component's role is crucial.

1. Page

The Page class is the new fundamental building block. It is not a widget itself but an immutable object that describes a widget in the navigator's history stack. It holds configuration for a route, including the widget to display and a unique key. When you provide a list of Page objects to the Navigator, it compares the new list to the old one. If a page with the same key and type exists in both lists, its widget might be updated. If a new page appears, a new route is pushed. If a page is removed, its corresponding route is popped.

Common implementations like MaterialPage or CupertinoPage also handle platform-specific transition animations.


// A page describing the HomeScreen widget.
MaterialPage(
  key: ValueKey('HomePage'),
  child: HomeScreen(),
);

// A page describing a DetailsScreen for a specific item.
MaterialPage(
  key: ValueKey('DetailsPage_123'),
  child: DetailsScreen(itemId: 123),
);

2. Router

The Router widget is the central coordinator. It sits at the top of your widget tree (usually configured via MaterialApp.router) and orchestrates the entire process. It doesn't manage state itself but wires together the other components: the RouteInformationProvider, the RouteInformationParser, and the RouterDelegate.

3. RouteInformationProvider

This component's job is to listen for routing information from the host platform and convert it into a standardized RouteInformation object. On the web, this means listening to URL changes in the browser's address bar. On mobile, it means listening for system-level navigation events, like the Android back button press. You typically don't need to implement your own, as Flutter provides PlatformRouteInformationProvider which does exactly this.

4. RouteInformationParser

The parser acts as a bidirectional translator. Its primary role is to bridge the gap between the platform-specific RouteInformation and your application's custom data model for navigation state.

  • parseRouteInformation: This method is called when the RouteInformationProvider reports a new route (e.g., the user types a new URL). It takes the RouteInformation (which contains the location string, like "/items/42") and converts it into a developer-defined data type, which we'll call a "configuration" or "route path" object. This is the URL-to-State conversion.
  • restoreRouteInformation: This method does the opposite. It takes your application's route path object and converts it back into a RouteInformation object. This is used to update the browser's URL when your app's state changes from within. This is the State-to-URL conversion.

5. RouterDelegate

This is the heart and soul of your navigation logic. The RouterDelegate is responsible for two critical tasks:

  1. Managing Application State: It holds the current navigation state. This could be as simple as an enum for the current page or as complex as a list of selected items, search queries, and tab indices.
  2. Building the Navigator: It listens to changes in its own state and, in its build method, constructs a Navigator widget with a stack of Page objects that represents that state.

The RouterDelegate must implement several key methods:

  • build(BuildContext context): Returns the Navigator widget. The list of pages passed to this navigator is a direct reflection of the delegate's current state.
  • setNewRoutePath(T configuration): This is called by the Router after the `RouteInformationParser` has successfully parsed a new route. The delegate's job here is to update its internal state based on this new configuration, which will then trigger a rebuild.
  • currentConfiguration: A getter that returns the current application state as the configuration object. The Router calls this to get the current state and pass it to the parser's restoreRouteInformation method to update the URL.

The delegate often uses ChangeNotifier to notify the Router of internal state changes (e.g., a button press that should navigate to a new screen), and the PopNavigatorRouterDelegateMixin to correctly handle system back button events.

A Practical Implementation: Building Stateful Navigation

Let's build a simple but robust navigation system for an app that shows a list of books and a detail page for each book. This will demonstrate how the components work together and set the stage for identifying the current page.

Step 1: Define the Navigation State Object (The "Configuration")

Instead of a simple enum, we'll create a class to represent our navigation state. This is more scalable as it can hold data like IDs.


class AppRoutePath {
  final int? id;
  final bool isUnknown;

  // Represents the home screen: /
  AppRoutePath.home()
      : id = null,
        isUnknown = false;

  // Represents the details screen: /book/123
  AppRoutePath.details(this.id) : isUnknown = false;

  // Represents an unknown/404 route
  AppRoutePath.unknown()
      : id = null,
        isUnknown = true;

  bool get isHomePage => id == null && !isUnknown;
  bool get isDetailsPage => id != null;
}

Step 2: Implement the RouteInformationParser

This class will parse URL strings into our AppRoutePath object and vice-versa.


class AppRouteInformationParser extends RouteInformationParser<AppRoutePath> {
  @override
  Future<AppRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.location ?? '');

    // Handle '/'
    if (uri.pathSegments.isEmpty) {
      return AppRoutePath.home();
    }

    // Handle '/book/:id'
    if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'book') {
      final id = int.tryParse(uri.pathSegments[1]);
      if (id != null) {
        return AppRoutePath.details(id);
      }
    }

    // Handle unknown routes
    return AppRoutePath.unknown();
  }

  @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;
  }
}

Step 3: Implement the RouterDelegate

This is the core logic. It holds the state and builds the navigator stack.


class AppRouterDelegate extends RouterDelegate<AppRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoutePath> {

  // The delegate's navigatorKey is required to use PopNavigatorRouterDelegateMixin
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  // Internal state of the application
  int? _selectedBookId;
  bool _show404 = false;

  AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  // This getter is called by the Router to determine the current state.
  // This state is then passed to the RouteInformationParser to update the URL.
  @override
  AppRoutePath get currentConfiguration {
    if (_show404) {
      return AppRoutePath.unknown();
    }
    if (_selectedBookId != null) {
      return AppRoutePath.details(_selectedBookId);
    }
    return AppRoutePath.home();
  }

  // This is where we build the stack of pages based on our internal state.
  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        MaterialPage(
          key: ValueKey('BookListPage'),
          child: BookListScreen(
            onBookTapped: (id) {
              // Internal state change
              _selectedBookId = id;
              notifyListeners(); // Rebuilds the navigator
            },
          ),
        ),
        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;
        }

        // When a page is popped, update the state accordingly.
        if (_selectedBookId != null) {
          _selectedBookId = null;
        }
        _show404 = false;
        notifyListeners(); // Rebuilds the navigator
        return true;
      },
    );
  }

  // This is called by the Router when a new route is parsed.
  // We must update our state to reflect the new route path.
  @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;
    }
    // No notifyListeners() needed here, as the Router triggers a rebuild
    // after this Future completes.
  }
}

Step 4: Wire It Up in `MaterialApp.router`

Finally, we replace the standard MaterialApp constructor with MaterialApp.router.


void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _routerDelegate = AppRouterDelegate();
  final _routeInformationParser = AppRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Navigator 2.0 Example',
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeInformationParser,
    );
  }
}

The Core Question: How to Identify the Current Page?

With this foundation, we can now definitively answer how to determine the current page. The key insight is to rephrase the question from "What widget is on screen?" to "What is the current navigation state?". The state is the source of truth, and the widget is merely its visual representation.

Method 1: Querying the RouterDelegate's State (The Recommended Approach)

The most reliable and idiomatic way to know "where you are" in a Navigator 2.0 application is to access the state held within your `RouterDelegate`. Since the delegate is the single source of truth for the navigation stack, querying it gives you the most accurate information.

You can access your delegate from anywhere in the widget tree below the `Router` using `Router.of(context)`:


class SomeWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Get the top-level RouterDelegate
    final delegate = Router.of(context).routerDelegate as AppRouterDelegate;

    // Now you can directly query its state.
    // NOTE: This gives you the raw state, not the configuration object.
    // You would need to add public getters to your delegate for this.
    
    // For example, if you add this getter to AppRouterDelegate:
    // int? get selectedBookId => _selectedBookId;
    
    final currentBookId = delegate.selectedBookId;
    
    if (currentBookId != null) {
      return Text('Currently viewing book with ID: $currentBookId');
    } else {
      return Text('Currently on the home screen');
    }
  }
}

A cleaner way is to use the delegate's public `currentConfiguration` property, which represents the state as a serializable `AppRoutePath` object. This is often better as it decouples the widget from the internal implementation details of the delegate.


class AnotherWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final delegate = Router.of(context).routerDelegate as AppRouterDelegate;
    final currentPath = delegate.currentConfiguration;

    if (currentPath.isDetailsPage) {
      return Text('Details page for book ${currentPath.id}');
    } else if (currentPath.isHomePage) {
      return Text('Home Page');
    } else {
      return Text('Unknown Page');
    }
  }
}

This is the most powerful method. It is declarative, respects the source of truth, and is highly testable. You can mock the `AppRouterDelegate` in your widget tests to simulate any navigation state.

Method 2: Using a RouteObserver

For use cases that need to react to the *event* of a page being pushed or popped (like analytics tracking or managing subscriptions), the `RouteObserver` remains a valid pattern. It can be integrated into a Navigator 2.0 setup.

  1. Add the observer to your delegate:
    
    // In your AppRouterDelegate
    final routeObserver = RouteObserver<PageRoute>();
    
    @override
    Widget build(BuildContext context) {
      return Navigator(
        key: navigatorKey,
        pages: [ ... ],
        observers: [routeObserver], // Add it here
        onPopPage: (route, result) { ... },
      );
    }
            
  2. Use the `RouteAware` mixin on your page's widget:
    
    class BookDetailsScreen extends StatefulWidget {
      // ...
      @override
      _BookDetailsScreenState createState() => _BookDetailsScreenState();
    }
    
    class _BookDetailsScreenState extends State<BookDetailsScreen> with RouteAware {
      // Assume you get the observer via Provider or another DI method
      late final RouteObserver<PageRoute> routeObserver; 
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        // Subscribe to the observer
        routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
      }
    
      @override
      void dispose() {
        routeObserver.unsubscribe(this);
        super.dispose();
      }
    
      @override
      void didPush() {
        // This screen was pushed onto the stack
        print('Entered Book Details Screen');
      }
    
      @override
      void didPopNext() {
        // The route on top of this one was popped, and this screen is now visible again.
      }
    }
            

This method is event-driven. It tells you when a navigation event happens but is less suited for querying the current static state compared to reading it from the delegate.

Conclusion: State is the Destination

Flutter's Navigator 2.0 represents a mature, robust solution for application navigation that aligns with modern, state-driven UI principles. While the initial setup involves more boilerplate code, the benefits in predictability, testability, and handling of platform integrations like web URLs are profound.

The key to "getting the current page" is to stop thinking about the widget stack and start thinking about the application state. The RouterDelegate becomes the definitive source of truth for your navigation state. By querying its state or its public `currentConfiguration`, you can reliably and declaratively determine where the user is in your application. This state-centric approach not only solves the immediate problem but also lays the foundation for building scalable, maintainable, and deeply integrated Flutter applications for any platform.


0 개의 댓글:

Post a Comment