Understanding the Benefits of Declarative Navigation in Flutter

In Flutter development, navigation is a fundamental aspect of creating a user-friendly and interactive application. Traditionally, Flutter developers have relied on imperative navigation using Navigator.push and Navigator.pop. However, the Flutter framework has evolved to support declarative navigation, offering a more reactive and manageable approach. This blog post delves into the benefits of declarative navigation in Flutter and provides practical examples to illustrate its advantages.

What is Declarative Navigation?

Declarative navigation in Flutter involves describing the desired state of the navigation stack using data. Instead of explicitly pushing or popping routes using imperative commands, you define the application’s navigation state based on your application’s data model. The framework then updates the UI to match the defined state. This approach aligns well with Flutter’s declarative UI building paradigm.

Why Use Declarative Navigation?

Declarative navigation offers several key advantages over imperative navigation:

  • Centralized Navigation Logic: The navigation logic is centralized and tied to the application’s state, making it easier to understand and maintain.
  • Improved Testability: Since navigation is driven by data, testing the navigation flow becomes more straightforward.
  • Better State Management Integration: Declarative navigation works seamlessly with various state management solutions like Provider, Riverpod, BLoC, and Redux.
  • Enhanced Control: Easier to handle complex navigation scenarios, such as deep linking and conditional routing.

Implementing Declarative Navigation in Flutter

To implement declarative navigation, you typically use a Router widget along with a RouterDelegate and a RouteInformationParser.

Step 1: Create a Custom RouterDelegate

The RouterDelegate is responsible for managing the app’s navigation state and building the UI based on the current route. Create a class that extends RouterDelegate and implement the necessary methods.


import 'package:flutter/material.dart';

class MyRouterDelegate extends RouterDelegate with ChangeNotifier {
  String _currentRoute = '/home';

  String get currentConfiguration => _currentRoute;

  @override
  Widget build(BuildContext context) {
    return Navigator(
      pages: [
        if (_currentRoute == '/home')
          MaterialPage(child: HomeScreen(onNavigateToDetails: () {
            _currentRoute = '/details';
            notifyListeners();
          })),
        if (_currentRoute == '/details')
          MaterialPage(child: DetailsScreen(onNavigateToHome: () {
            _currentRoute = '/home';
            notifyListeners();
          })),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }
        _currentRoute = '/home';
        notifyListeners();
        return true;
      },
    );
  }

  @override
  Future setNewRoutePath(String configuration) async {
    _currentRoute = configuration;
    notifyListeners();
  }
}

class HomeScreen extends StatelessWidget {
  final VoidCallback onNavigateToDetails;

  HomeScreen({required this.onNavigateToDetails});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: Center(
        child: ElevatedButton(
          child: Text('Go to Details'),
          onPressed: onNavigateToDetails,
        ),
      ),
    );
  }
}

class DetailsScreen extends StatelessWidget {
  final VoidCallback onNavigateToHome;

  DetailsScreen({required this.onNavigateToHome});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Details')),
      body: Center(
        child: ElevatedButton(
          child: Text('Go to Home'),
          onPressed: onNavigateToHome,
        ),
      ),
    );
  }
}

Step 2: Create a RouteInformationParser

The RouteInformationParser is responsible for parsing the route information from the operating system and converting it into a data structure that the RouterDelegate can understand.


import 'package:flutter/material.dart';

class MyRouteInformationParser extends RouteInformationParser {
  @override
  Future parseRouteInformation(RouteInformation routeInformation) async {
    return routeInformation.uri.toString();
  }

  @override
  RouteInformation? restoreRouteInformation(String configuration) {
    return RouteInformation(uri: Uri.parse(configuration));
  }
}

Step 3: Use the Router in Your App

Finally, use the Router widget in your MaterialApp and provide the RouterDelegate and RouteInformationParser.


import 'package:flutter/material.dart';

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

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

class _MyAppState extends State {
  late MyRouterDelegate _routerDelegate;
  late MyRouteInformationParser _routeInformationParser;

  @override
  void initState() {
    super.initState();
    _routerDelegate = MyRouterDelegate();
    _routeInformationParser = MyRouteInformationParser();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeInformationParser,
      backButtonDispatcher: RootBackButtonDispatcher(),
    );
  }
}

Implementing Navigation with State Management

Declarative navigation shines when combined with state management solutions. Here’s an example using Provider:


import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class NavigationState extends ChangeNotifier {
  String _currentRoute = '/home';

  String get currentRoute => _currentRoute;

  void navigateTo(String route) {
    _currentRoute = route;
    notifyListeners();
  }
}

class MyRouterDelegate extends RouterDelegate with ChangeNotifier {
  @override
  Widget build(BuildContext context) {
    final navigationState = Provider.of(context);
    final currentRoute = navigationState.currentRoute;

    return Navigator(
      pages: [
        if (currentRoute == '/home')
          MaterialPage(child: HomeScreen(onNavigateToDetails: () {
            navigationState.navigateTo('/details');
          })),
        if (currentRoute == '/details')
          MaterialPage(child: DetailsScreen(onNavigateToHome: () {
            navigationState.navigateTo('/home');
          })),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }
        navigationState.navigateTo('/home');
        return true;
      },
    );
  }

  @override
  Future setNewRoutePath(String configuration) async {
    Provider.of(context, listen: false).navigateTo(configuration);
  }

  @override
  String? get currentRouteName => Provider.of(context).currentRoute;
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => NavigationState(),
      child: MyApp(),
    ),
  );
}

Conclusion

Declarative navigation in Flutter offers a powerful and maintainable approach to managing your application’s navigation flow. By centralizing navigation logic and tying it to your application’s data model, you can create more testable, scalable, and maintainable Flutter applications. Integrating declarative navigation with state management solutions like Provider, Riverpod, BLoC, or Redux further enhances its benefits, providing a robust and reactive navigation system.