Navigation in Flutter: From Basics to Advanced Techniques

Navigation in Flutter mobile applications refers to the system that allows users to move between different sections or screens within an app to complete tasks.

The Ultimate Guide for Mastering Navigation in Flutter

Navigation is a crucial aspect of app design as it directly impacts how users interact with the app and access its features. In the context of mobile development, Flutter provides a robust mechanism for managing navigation through a concept known as routing. Routing in Flutter is the process of defining and handling different routes (or pages) within the app. Effective routing enhances the user experience and simplifies the development process.

In this blog, we'll delve into the world of navigation in Flutter, from basics to advanced concepts with code examples.

>> Read more:

What is Navigation?

Navigation is the act of moving between screens of an app to complete tasks (according to Material Design - Google’s design language). It’s enabled through several means: dedicated navigation components, embedding navigation behaviour into content, and platform affordances.

Smooth and intuitive navigation ensures that users can find what they need quickly and efficiently, leading to a positive experience. Conversely, poor navigation can result in frustration and may cause users to abandon the app altogether.

3 Types of Navigation

Base on the behaviour of a mobile application, there can be three types of navigation:

Lateral Navigation

Lateral navigation refers to movement between screens at the same level of hierarchy. It enables access to different app destinations and features, or pivoting between related items in a set.

The main navigation component of an app should offer access to all top-level destinations. For apps with multiple top-level destinations, lateral navigation can be facilitated using a navigation drawer, bottom navigation bar, or tabs.

Tab Navigation

Bottom Navigation

Forward Navigation

Whereas lateral navigation utilises specific navigation components, forward navigation is typically integrated into a screen's content using various components.

The easiest example to understand this would be from a page containing a list of items, you will navigate forward (as going deeper into the hierarchy) to the item detail page.

Reverse Navigation

Reverse navigation refers to backward movement between screens. It can move users chronologically through their recent screen history, or upwards through an app's hierarchy.

Reverse chronological navigation involves moving backward through a user's recently viewed screens in the order they were accessed. This navigation can take users between screens within a single app or across multiple apps. An example of this is the Back button on a web browser.

This navigation method is generally provided by the operating system or platform, which determines its behaviour and how users can access it.

Key Concepts of Navigation in Flutter

The Flutter framework provides developers a way to navigate around the app with two main navigating classes that can be understood exactly to their names: Route and Navigator. Route acts as the screen and Navigator acts as the operator to manage Route.

Route

In Flutter, a Route is an abstraction for a screen or page that can be navigated to. It represents the transition from one screen to another and is managed by the Navigator widget, which maintains a stack of routes. Understanding how routes work and how to use them effectively is crucial for creating smooth and intuitive navigation experiences in Flutter applications.

As an abstraction class, Route is extended to provide the base properties and method for navigating. This action creates many different Route types that enable multiple transition style and can automatically adapt to different platforms (Android or iOS).

Here is some common Route implemented class in Flutter:

  • MaterialPageRoute

MaterialPageRoute includes built-in transitions that comply with Material Design guidelines, such as a fade transition combined with a scale transition, providing a seamless and smooth user experience. The transitions are subtle and polished, enhancing the overall aesthetics of the application without the need for custom animations.

MaterialPageRoute is particularly suitable for Android applications, which are expected to follow these guidelines. It ensures that the navigation experience feels native to Android users, aligning with their expectations of app behaviour on the platform.

>> Read more: Top 5 Best Languages For Android App Development

Example:

typescript
Navigator.push(
  context,
  MaterialPageRoute(
	  builder: (context) => MyNewScreen(),
  ),
);
  • CupertinoPageRoute

CupertinoPageRoute is specifically intended for apps that adhere to Cupertino design guidelines, which are Apple’s design language for creating elegant, clean, and intuitive user interfaces. It ensures that the app’s navigation behaviour is consistent with other iOS applications, providing a familiar user experience for iOS users.

CupertinoPageRoute implements transitions that mimic the default transitions in iOS, such as the sliding motion from right to left when navigating to a new page and from left to right when popping a page. The transitions are smooth and follow the physics and aesthetic typical of iOS, enhancing the feeling of a native iOS application.

Example:

typescript
Navigator.push(
  context,
  CupertinoPageRoute(
	  builder: (context) => MyNewScreen(),
  ),
);

>> Read more: Top 6 Best iOS App Development Languages For Programmers

  • PageRouteBuilder

PageRouteBuilder is a powerful and flexible class in Flutter that allows developers to create custom route transitions. Unlike MaterialPageRoute and CupertinoPageRoute, which provide predefined transitions based on Material Design and Cupertino (iOS) design respectively, PageRouteBuilder gives you full control over the transition animations used when navigating between pages.

PageRouteBuilder accepts pageBuilder and transitionsBuilder parameters, giving you the flexibility to define exactly how the new page should appear and disappear. The pageBuilder is used to create the widget for the new route, while the transitionsBuilder is used to define the animation between the current and new route. You can utilise various types of animations such as fading, scaling, sliding, or a combination of these to create complex and visually appealing transitions.

Example:

typescript
Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (
      context,
      animation,
      secondaryAnimation,
    ) =>
        const DetailsScreen(),
    transitionDuration: const Duration(seconds: 1),
    transitionsBuilder: (
      context,
      animation,
      secondaryAnimation,
      child,
    ) {
      return FadeTransition(
        opacity: animation,
        child: child,
      );
    },
  ),
);

In this example, we created a new transition to DetailScreen() using a transition builder to create a custom FadeTransition(). We have a beautiful and smooth (and a little slow for better clarity) result as follow:

Navigator

The Navigator widget in Flutter is essential for managing the navigation stack and facilitating transitions between screens (routes). It operates similarly to a stack of plates, where each plate represents a screen. You can push a new screen onto the stack or pop the current screen off the stack to return to the previous one.

Here is some core concepts of Navigator:

  • Navigation Stack

The Navigator’s stack-based approach to managing routes provides a robust and flexible system for handling screen transitions in Flutter applications. By maintaining a stack of Route objects, the Navigator ensures that the current screen is always at the top of the stack, simplifying the process of navigating between screens and managing back navigation.

Understanding how to effectively use the Navigator’s stack operations is essential for creating smooth and intuitive user experiences in Flutter apps. Look through the diagram below for further visualization:

Navigator Stack Operation Diagram
Navigator Stack Operation Diagram
  • Push Routes

Push the given route onto the navigator that most tightly encloses the given context. The most common one is Navigator.push().

The new route and the previous route (if any) are notified . If the Navigator has any Navigator.observers, they will be notified as well. Ongoing gestures within the current route are canceled when a new route is pushed.

There are multiple other ways to push a screen: pushNamed, pushReplacement, restorablePush,… with many different purposes of usage. All of these will be explain in the later section of this blog.

Typical usage of Navigator.push():

typescript
Navigator.push(
  context,
  MaterialPageRoute(builder:
		(context) => NewScreen(),
	),
);

Or with Navigator.pushNamed():

typescript
Navigator.pushNamed(
	context, 
	'/newScreen',
);

Navigator.pushNamed() is used for named route navigation in Flutter. This method allows you to navigate to a new screen by referring to the route’s name rather than its constructor. Named routes are typically defined in the MaterialApp or CupertinoApp widget through routes property.

With pushReplacement():

typescript
Navigator.pushReplacement(
  context,
  MaterialPageRoute(
	  builder: (context) => AnotherScreen(),
	),
);

Navigator.pushReplacement() is used to replace the current route with a new route. This means that the new route will replace the current route in the navigation stack, and the user won’t be able to navigate back to the original route.

  • Pop Routes

Pop the top-most route off the navigator that most tightly encloses the given context.

The current route's Route.didPop method is called first. If that method returns false, then the route remains in the Navigator's history (the route is expected to have popped some internal state). Otherwise, if non-null, result will be used as the result of the route that is popped; the future that had been returned from pushing the popped route will complete with result.

Routes such as dialogs or popup menus typically use this mechanism to return the value selected by the user to the widget that created their route. The type of result, if provided, must match the type argument of the class of the popped route.

There are multiple other ways to pop a screen such as: popUntil, popAndPushNamed… with different purposes of usage. All of these will be explained in the later section of this blog.

Most common method for pop a route is Navigator.pop()

Example:

typescript
Navigator.pop(context);

Or with Navigator.popUntil():

typescript
Navigator.popUntil(
	context, 
	ModalRoute.withName('/home'),
);

This method pops routes off the navigation stack until a specified condition is met. This method is useful for navigating back to a specific screen without knowing exactly how many screens have been pushed onto the stack since that screen was last visited.

The code in popUntil() example means that Navigator will pop all the route in stack until it finds a route named “/home” (ModalRoute.withName(’/home’) is a predicate that checks a route name.).

A Practical Example: Implementing Navigation in Flutter

To understand how Navigator and Route work better, nothing beats an example code along each section. In this section, we’ll try to go through as many aspect of Flutter Navigation as possible.

Basic Routing

For the basics, we’ll create a simple app with two screens: HomeScreen() and DetailsScreen(). We’ll demonstrate how to navigate between these screens.

typescript
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home Screen'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => const DetailsScreen(),
              ),
            );
          },
          child: const Text('Go to Details Screen'),
        ),
      ),
    );
  }
}

First, let’s create a HomeScreen() where we will have a button to navigate to DetailsScreen(). We will try with the basic Navigator.push() method to navigate to DetailsScreen().

To have a DetailScreen() to navigate to, let’s implement it:

typescript
class DetailsScreen extends StatelessWidget {
  const DetailsScreen({super.key});

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

This will be a simple screen with a “Details Screen” title and a back button on the app bar and in the middle of the screen.

Now let’s put it to our main function and MaterialApp:

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

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomeScreen(),
    );
  }
}

Let’s see how it looks like on our simulator:

Here’s something for us to take a look at:

Navigating with push with a default MaterialPageRoute will create a transition effect (slide new screen from right to left) which will also be the effect when we pop (the screen will go backward from left to right).

By default, when pushed to a MaterialPageRoute, the AppBar widget will have an adaptive icon (left arrow for Android and chevron for iOS) which is assigned with a Navigator.pop() method.

Let’s change our app to name route approach with a few more screens to navigate.

Navigate with Named Routes

Let’s add some more screens to our app and update the old screen.

We will update our DetailsScreen():

typescript
class DetailsScreen extends StatelessWidget {
  const DetailsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Details Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: const Text('Go back'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pushReplacementNamed(
                  context,
                  '/',
                );
              },
              child: const Text('Replace with Home Screen'),
            ),
          ],
        ),
      ),
    );
  }
}

In DetailsScreen() we will now have two buttons: back button using Navigator.pop() and Replace with Home Screen button using Navigator.pushReplacementNamed() which will push the ‘/’ route as HomeScreen().

We will add two more screens: ProfileScreen() and SettingScreen():

typescript
class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Profile Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                Navigator.popUntil(
                  context,
                  ModalRoute.withName('/'),
                );
              },
              child: const Text('Go back to Home Screen using popUntil'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pushReplacementNamed(
                  context,
                  '/settings',
                );
              },
              child: const Text('Replace with Settings Screen'),
            ),
          ],
        ),
      ),
    );
  }
}
typescript
class SettingsScreen extends StatelessWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Settings Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: const Text('Go back'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pushNamed(
                  context,
                  '/details',
                );
              },
              child: const Text('Go to Details Screen'),
            ),
          ],
        ),
      ),
    );
  }
}

In ProfileScreen() will contains a button to use Navigator.popUntil() to return to the HomeScreen() and also demonstrates Navigator.pushReplacementNamed() to replace the current screen with SettingsScreen().

In SettingScreen() contains a button to pop the current route off the stack, returning to the previous screen and demonstrates Navigator.pushNamed() to navigate to DetailsScreen().

We will now update our HomeScreen() and MaterialApp as follow:

typescript
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                Navigator.pushNamed(
                  context,
                  '/details',
                );
              },
              child: const Text('Go to Details Screen'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pushNamed(
                  context,
                  '/profile',
                );
              },
              child: const Text('Go to Profile Screen'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.pushNamed(
                  context,
                  '/settings',
                );
              },
              child: const Text('Go to Settings Screen'),
            ),
          ],
        ),
      ),
    );
  }
}

Here, we will just basically use Navigator.pushName() for all three new screens we have. And finally we will update our MaterialApp:

typescript
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => const HomeScreen(),
        '/details': (context) => const DetailsScreen(),
        '/profile': (context) => const ProfileScreen(),
        '/settings': (context) => const SettingsScreen(),
      },
    );
  }
}

Different from the unnamed route approach, we will need to clarify the routes property in MaterialApp. Accordingly, Flutter can tell which screen we are referring to when we push its name in HomeScreen().

Let’s see how it works:

Send and Return Data To/From A Screen

Question arises! We have a list of items in one screen and want to see the detail of that item in a detail screen. When we change or modify that item, the change will show on the list as we move back. How can we do this?

The solution is to pass some metadata of the item from the list screen to the detail screen and back. This involves sending data from one screen to another. Flutter Navigator helps with this by allowing you to send data as an argument to a different route.

Let’s start with our ItemListScreen():

typescript
class ItemListScreen extends StatefulWidget {
  const ItemListScreen({super.key});

  @override
  State<ItemListScreen> createState() => _ItemListScreenState();
}

class _ItemListScreenState extends State<ItemListScreen> {
  List<String> items = ['Item 1', 'Item 2', 'Item 3'];
  String selectedItem = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Item List'),
      ),
      body: ListView.builder(
        itemCount: items.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(items[index]),
            onTap: () async {
              // Navigate to the detail screen and wait for the result
              final result = await Navigator.pushNamed(
                context,
                '/details',
                arguments: items[index],
              );

              // If a result was returned, update the selectedItem
              if (result != null) {
                setState(() {
                  selectedItem = result as String;
                });
              }
            },
          );
        },
      ),
      bottomNavigationBar: Visibility(
        visible: selectedItem.isNotEmpty,
        child: Padding(
          padding: const EdgeInsets.all(32.0),
          child: Text(
            'Selected Item: $selectedItem',
            style: const TextStyle(fontSize: 18),
          ),
        ),
      ),
    );
  }
}

Every “push” related method in Navigator there is a parameter to pass the arguments into. Flutter Navigator will then bring those arguments to the Route (screen) that require the argument. Moreover, to catch the data sent back from the ItemDetailScreen(), push method also returns an Object and we can parse that Object to the desired data.

Let see how we handle the data send from ItemListScreen() and how to send data back to it from ItemDetailScreen().

typescript
class ItemDetailScreen extends StatelessWidget {
  const ItemDetailScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Receive the item data from the previous screen
    final String item = ModalRoute.of(context)?.settings.arguments as String;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Item Detail'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Details for $item',
              style: const TextStyle(fontSize: 24),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // Send data back to the previous screen
                Navigator.pop(context, '$item - Selected');
              },
              child: const Text('Select This Item'),
            ),
          ],
        ),
      ),
    );
  }
}

The ItemDetailScreen() is designed to receive data from the previous screen. This is achieved using the *ModalRoute.of(context)?.settings.arguments* property, which retrieves the arguments passed when navigating to this route. In our example, the argument is a string representing the item selected in the ItemListScreen().

The screen includes a button that allows the user to confirm their selection. When this button is pressed, the screen sends data back to the ItemListScreen() and pops itself off the navigation stack. This is achieved using the Navigator.pop() method, which can also carry a result back to the previous screen.

In this case, Navigator.pop() is used to pass a modified string back to the ItemListScreen(), indicating the item that was selected. The string '$item - Selected' combines the original item name with additional text to create a result string that the ItemListScreen() can process.

Finally let’s add them to our MaterialApp and see the result:

typescript
void main() {
  runApp(
    const MyApp(),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => const ItemListScreen(),
        '/details': (context) => const ItemDetailScreen(),
      },
    );
  }
}

Our app should look and behave like this:

Advanced Techniques of Navigation in Flutter

Navigation 2.0

Over the time, Flutter has evolved its navigation system to provide more flexibility and control over the navigation flow in applications. With the release of Flutter 1.22, Navigation 2.0 was introduced, bringing significant improvements. Since then, many libraries and updates from the Flutter framework have been introduced to enhance navigation.

Previously, Navigation 1.0 used the Navigator and Route classes to manage navigation stacks imperatively. Developers would push and pop routes to navigate between screens. While this model was straightforward for simple navigation scenarios, it lacked flexibility for more complex navigation patterns, such as deep linking or dynamic routing.

Navigation 1.0's limitations led to the introduction of Navigation 2.0, which uses a Declarative Navigation Model. This model introduces the Router widget and RouteInformationParser, allowing for a more declarative approach to navigation.

Navigation 2.0 is particularly beneficial for web applications, as it synchronizes the browser’s URL with the app’s navigation state. Moreover, Navigation 2.0 makes handling deep linking and dynamic routing more manageable, enabling more complex and flexible navigation flows. Deep linking with Navigation 2.0 will be discused more thorough in the next section.

Let’s go through with an example to have a better understanding!

First we will create some simple screens:

typescript
class HomeScreen extends StatelessWidget {
  final ValueChanged<String> onItemSelected;

  const HomeScreen({
    super.key,
    required this.onItemSelected,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Item 1'),
            onTap: () => onItemSelected('1'),
          ),
          ListTile(
            title: const Text('Item 2'),
            onTap: () => onItemSelected('2'),
          ),
        ],
      ),
    );
  }
}

class ItemDetailScreen extends StatelessWidget {
  final String? id;

  const ItemDetailScreen({
    super.key,
    required this.id,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Item $id')),
      body: Center(child: Text('Details for Item $id')),
    );
  }
}

class UnknownScreen extends StatelessWidget {
  const UnknownScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Unknown Page')),
      body: const Center(child: Text('404 - Page not found')),
    );
  }
}

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: MyRouteInformationParser(),
      routerDelegate: MyRouterDelegate(),
    );
  }
}

We will have a list with two items that onTap() will navigate to a ItemDetailScreen. We will also define a UnknownScreen for in case we programmatically navigate to a wrong route.

We need to declare *routerDelegate* and *routeInformationParser* for our MaterialApp. Let’s see the implementation code for them:

typescript
class MyRoutePath {
  final String? id;
  final bool isUnknown;

  MyRoutePath.home()
      : id = null,
        isUnknown = false;
  MyRoutePath.details(this.id) : isUnknown = false;
  MyRoutePath.unknown()
      : id = null,
        isUnknown = true;

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

class MyRouteInformationParser extends RouteInformationParser<MyRoutePath> {
  @override
  Future<MyRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.uri.path);
    if (uri.pathSegments.isEmpty) {
      return MyRoutePath.home();
    } else if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'item') {
      return MyRoutePath.details(uri.pathSegments[1]);
    }
    return MyRoutePath.unknown();
  }

  @override
  RouteInformation restoreRouteInformation(MyRoutePath configuration) {
    if (configuration.isHomePage) {
      return RouteInformation(uri: Uri(path: '/'));
    }
    if (configuration.isDetailsPage) {
      return RouteInformation(uri: Uri(path: '/item/${configuration.id}'));
    }
    return RouteInformation(uri: Uri(path: '/unknown'));
  }
}

The information parser will parse the URL to extract routing information.

Finally is our *routeDelegate*:

typescript
class MyRouterDelegate extends RouterDelegate<MyRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRoutePath> {
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  String? _selectedItemId;
  bool show404 = false;

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

  @override
  MyRoutePath get currentConfiguration {
    if (show404) {
      return MyRoutePath.unknown();
    }
    return _selectedItemId == null ? MyRoutePath.home() : MyRoutePath.details(_selectedItemId);
  }

  @override
  Future<void> setNewRoutePath(MyRoutePath configuration) async {
    if (configuration.isUnknown) {
      show404 = true;
      return;
    }
    if (configuration.isDetailsPage) {
      _selectedItemId = configuration.id;
    } else {
      _selectedItemId = null;
    }
    show404 = false;
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        MaterialPage(
          key: const ValueKey('HomePage'),
          child: HomeScreen(
            onItemSelected: _handleItemSelected,
          ),
        ),
        if (show404)
          const MaterialPage(
            key: ValueKey('UnknownPage'),
            child: UnknownScreen(),
          )
        else if (_selectedItemId != null)
          MaterialPage(
            key: const ValueKey('ItemPage'),
            child: ItemDetailScreen(
              id: _selectedItemId,
            ),
          ),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }
        _selectedItemId = null;
        notifyListeners();
        return true;
      },
    );
  }

  void _handleItemSelected(String id) {
    _selectedItemId = id;
    notifyListeners();
  }
}

This class manages the navigation stack and handles route changes.

Above is a simplified version for implementing Navigation 2.0. Let’s see our code in action:

The route works normally and now we have a simple skeleton for our app to scale up our application.

Deep Linking

Deep linking in Flutter allows your app to be launched via a URL, taking the user directly to a specific screen or content within the app. This is especially useful for directing users to particular sections of your app from emails, social media, or web links.

There are two types of deep linking:

  1. Basic Deep Linking: Launches the app and opens a specific screen based on the URL.
  2. Deferred Deep Linking: Opens a specific screen based on the URL even if the app is not installed at the time the link is clicked. The user is directed to install the app first, and then the specific screen is opened.

Flutter supports deep linking on iOS, Android, and the web. Opening a URL displays that screen in your app. You can launch and display routes by using named routes (either with the *routes* parameter or *onGenerateRoute*), or the Router widget.

If you're using the app in a web browser, no extra setup is needed. Route paths function similarly to deep links on iOS or Android. By default, web apps interpret the deep link path from the URL fragment using the pattern: /#/path/to/app/screen. However, you can modify this by configuring your app's URL strategy.

To have a deeper understand on deep linking with native platform (Android and iOS), prefer to the official Flutter document. For our example from the start, we should have an adjustment on our parser in *routeInformationParser* and we are good to go:

typescript
class MyRouteInformationParser extends RouteInformationParser<MyRoutePath> {
  @override
  Future<MyRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.uri.path);
    if (uri.pathSegments.isEmpty) {
      return MyRoutePath.home();
    } else if (uri.pathSegments.length == 2) {
      if (uri.pathSegments[0] == 'item') {
        var id = uri.pathSegments[1];
        return MyRoutePath.details(id);
      }
    }
    return MyRoutePath.unknown();
  }

  @override
  RouteInformation restoreRouteInformation(MyRoutePath configuration) {
    if (configuration.isHomePage) {
      return RouteInformation(uri: Uri(path: '/'));
    }
    if (configuration.isDetailsPage) {
      return RouteInformation(uri: Uri(path: '/item/${configuration.id}'));
    }
    return RouteInformation(uri: Uri(path: '/unknown'));
  }
}

Custom Page Transition 

Creating a better, smoother, and more visually appealing Flutter application involves making transitions seamless and creative.

Flutter provides many default transition animations, such as FadeTransition(), SlideTransition(), AlignTransition(), etc. Let's take a look at some of these examples to understand how they work.

For custom transition animations, we can wrap our page in the PageRouteBuilder() class with a custom page to achieve the desired animation effects.

First, we will create our FadeTransitionPageRoute() to implement the transition animation:

typescript
class FadeTransitionPageRoute extends PageRouteBuilder {
  final Widget page;

  FadeTransitionPageRoute({
    required this.page,
  }) : super(
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) {
            return page;
          },
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) {
            return FadeTransition(
              opacity: animation,
              child: child,
            );
          },
        );
}

We will use Animation class to handle the animation of our transition. Let’s create the other screen and update our MaterialApp again. This time we will create a simple HomeScreen() and DetailScreen():

typescript
class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home Screen'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
			        FadeTransitionPageRoute(
                page: const DetailScreen(),
              ),
            );
          },
          child: const Text('Go to Detail Screen'),
        ),
      ),
    );
  }
}

class DetailScreen extends StatelessWidget {
  const DetailScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Detail Screen'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).pop();
          },
          child: const Text('Back to Home Screen'),
        ),
      ),
    );
  }
}

Let’s finish it all in the MaterialApp:

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

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Custom Transition',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomeScreen(),
    );
  }
}

And now we have a fading effect for our application every time we navigate through a screen:

Let’s try with some harder effect, we will create a MatrixTransitionPageRoute() to perform a 3D transition with the screen rotating around the Y-axis:

typescript
class MatrixTransitionPageRoute extends PageRouteBuilder {
  final Widget page;

  MatrixTransitionPageRoute({
    required this.page,
  }) : super(
          pageBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
          ) {
            return page;
          },
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> secondaryAnimation,
            Widget child,
          ) {
            return AnimatedBuilder(
              animation: animation,
              builder: (context, child) {
                double value = animation.value;
                return Transform(
                  transform: Matrix4.identity()
                    ..setEntry(3, 2, 0.001)
                    ..rotateY(value * pi * 2), // Rotate 360 degrees
                  alignment: Alignment.center,
                  child: child,
                );
              },
              child: child,
            );
          },
        );
}

Let’s see our code in action:

That’s a very cool (and dizzy) animation effect we got there.

Navigation Packages in Flutter

Flutter provides built-in navigation capabilities through its Navigator and Route classes. However, for more complex navigation scenarios and extended functionalities, several third-party packages can be utilised. These packages offer additional features such as declarative navigation, nested navigation, and more.

go_router

go_router is a popular navigation package in Flutter that provides a robust and flexible approach to routing. It is designed to simplify navigation and routing in Flutter applications, offering a range of features that make it easier to manage complex navigation scenarios.

go_router adopts a declarative approach to navigation, allowing you to define your routes and navigation logic clearly and concisely. This approach makes it easier to understand and manage the navigation flow within your app. The package includes automatic route information parsing, simplifying the process of extracting and using route parameters. This feature is particularly useful for handling dynamic and complex route structures.

Key features of go_router include:

  • Code Generation: Reduces boilerplate code and potential errors by automatically generating route definitions and navigation methods, ensuring consistency across your app.
  • Nested Navigation: Provides robust support for creating and managing nested navigation stacks, beneficial for apps with complex navigation hierarchies.
  • Redirects and Route Guards: Allows you to define redirects and route guards, giving you fine-grained control over access to specific routes. This is useful for implementing authentication and authorization logic within your app.
  • Web Integration: Enables URL-based navigation that works seamlessly with Flutter web, ensuring that your app’s navigation can be managed via URLs, making it more intuitive for web users.

fluro

fluro is a lightweight routing package designed for Flutter applications, offering a simple and efficient way to manage navigation. Known for its simplicity and performance, fluro is an excellent choice for developers seeking a straightforward yet powerful routing solution.

fluro stands out for its lightweight and fast design, making it an ideal choice for performance-sensitive applications. Its minimalistic approach ensures minimal overhead on the app’s performance. The package provides a simple and intuitive API, making it easy to define routes and manage navigation, reducing the learning curve and allowing developers to quickly implement routing in their applications.

Key features of fluro include:

  • Route Parameters: Robust support for both query parameters and path parameters, offering flexibility to create dynamic routes that can handle various data-passing scenarios.
  • Route Handlers: Uses route handlers to manage the logic associated with different routes, allowing for custom actions to be executed when a route is accessed, providing greater control over navigation behavior.
  • Middleware Support: Allows the execution of pre-processing logic before a route is accessed, useful for implementing authentication checks, logging, or other conditional logic that needs to be evaluated before navigation.

beamer

beamer is a powerful and flexible navigation package for Flutter that simplifies the implementation of complex routing systems. It extends the capabilities of Flutter’s built-in navigation and routing mechanisms, making it easier to manage deep links, nested navigation, and dynamic route matching.

beamer uses a declarative approach to define routes, aligning with Flutter’s overall design philosophy. This approach makes it easier to understand and manage the routes within an application. Key features of beamer include:

  • Nested Navigation: Supports complex navigation structures where one part of the app can have its own navigator nested within a parent navigator.
  • Deep Linking: Provides robust support for deep linking, enabling the app to respond to URLs and navigate directly to specific pages, even if the app is not currently running.
  • Dynamic Route Matching: Capable of dynamically matching routes based on path patterns and parameters, offering more flexibility in handling various URL structures.
  • State Synchronization: Keeps the app’s state synchronized with the browser’s URL, particularly useful for web applications, ensuring the URL always reflects the current state of the app.
  • Integration with State Management: Easily integrates with popular Flutter state management solutions, allowing developers to manage navigation state along with other app states in a consistent manner.

Best Practices for Navigation in Flutter

Implementing effective navigation in Flutter applications is essential for creating a seamless and intuitive user experience. Here are some best practices to consider:

Use Named Routes for Simple Navigation

Named routes provide a centralized way to define and manage all the routes in the application, enhancing maintainability and readability. By defining routes in one place, it becomes easier to navigate between screens and keep track of the navigation flow, especially in larger applications. This practice aligns with Flutter’s design philosophy, promoting clean and organized code.

Leverage Advanced Navigation Packages for Complex Scenarios

For more complex navigation scenarios, consider using advanced navigation packages like beamer or go_router. These packages offer additional features like nested navigation, deep linking, and dynamic route matching, which can significantly simplify the implementation of intricate navigation flows.

Integrate State Management with Navigation

  • Integrating state management solutions like provider or riverpod with navigation ensures that the navigation state is handled consistently with other application states. This integration helps maintain a clean separation of concerns, making the codebase more maintainable and scalable.
  • Implementing route guards to control access to certain parts of the application based on conditions like authentication status is essential for creating secure applications. Route guards ensure that users can only access routes they are authorized to, enhancing overall security and user experience.

>> Read more:

Implement Custom Transition Animations

Using PageRouteBuilder() to create custom transition animations can enhance the user experience by providing smooth and visually appealing transitions between screens.

By following these best practices, developers can create robust, maintainable, and user-friendly Flutter applications.

>> Read more: Making HTTP Requests in Flutter with Dio: A Step-by-Step Tutorial

Conclusion

Navigating effectively within a Flutter application is crucial for creating a seamless and intuitive user experience. Throughout our discussion, we delved into various aspects of navigation in Flutter, from basic principles to advanced techniques and best practices.

Flutter’s Navigator widget manages a stack of routes, representing different screens. Basic navigation methods such as Navigator.push() and Navigator.pop() allow for straightforward page transitions. Named routes offer a clean way to manage and maintain routes across the app, providing a centralised approach that enhances code readability and maintainability.

In conclusion, mastering navigation in Flutter involves understanding basic navigation methods, implementing advanced techniques, leveraging custom transitions, ensuring secure access with route guards, handling deep linking, and following best practices. By adopting these strategies, developers can create well-structured, maintainable, and user-friendly Flutter applications that provide a seamless and intuitive experience for users.

>>> Follow and Contact Relia Software for more information!

  • development