Flutter Lesson 8: Routing and Navigation



This content originally appeared on DEV Community and was authored by Ge Ji

In mobile application development, routing and navigation are core mechanisms for implementing page transitions and user navigation experiences. Flutter provides a comprehensive routing management system that easily enables functionality like page switching, parameter passing, and data return. This lesson will detail the routing and navigation mechanisms in Flutter, helping you master various implementation methods for page transitions.

I. Basic Principles of Page Navigation

Routing management in Flutter is based on a stack data structure, using the Navigator widget to manage a route stack, enabling push and pop operations for pages.

1. Navigator and the Route Stack

Navigator is the core component for managing routes in Flutter. It maintains a stack of routes, where each route corresponds to a page:

  • When a new page opens, its corresponding route is “pushed” onto the top of the stack
  • When a page closes, its corresponding route is “popped” from the top of the stack
  • The currently displayed page is always the route at the top of the stack

In a Flutter application, MaterialApp automatically provides a Navigator, and we can obtain its instance using Navigator.of(context) for operations.

2. MaterialPageRoute

MaterialPageRoute is a Material Design-style route that automatically handles transition animations during page switching (like sliding left/right, fading in/out, etc.), making it suitable for most scenarios.

Basic usage example:

// First page
class FirstScreen extends StatelessWidget {
  const FirstScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('First Screen')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Navigate to the second screen
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => const SecondScreen(),
              ),
            );
          },
          child: const Text('Go to Second Screen'),
        ),
      ),
    );
  }
}

// Second page
class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Second Screen'),
        // Navigation bar back button
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () {
            // Return to previous page
            Navigator.pop(context);
          },
        ),
      ),
      body: const Center(
        child: Text('This is the second screen'),
      ),
    );
  }
}

Note: In Scaffold, the AppBar automatically adds a back button that triggers Navigator.pop(context) when clicked, so you usually don’t need to implement the back button manually.

3. Page Transition Animations

MaterialPageRoute provides properties to customize page transition animations:

MaterialPageRoute(
  builder: (context) => const DetailScreen(),
  // Transition animation duration
  transitionDuration: const Duration(milliseconds: 500),
  // Reverse transition animation duration
  reverseTransitionDuration: const Duration(milliseconds: 300),
  // Whether to maintain page state
  maintainState: true,
  // Whether it's a fullscreen dialog
  fullscreenDialog: true, // Uses up-down sliding animation
)

When fullscreenDialog is set to true, the page slides in from the bottom with a dialog-like animation effect.


II. Named Route Configuration

For complex applications, directly using MaterialPageRoute can lead to redundant code that’s difficult to maintain. Flutter provides a named routes mechanism for page navigation using route names, making route management more centralized and standardized.

1. Basic Configuration

Configure named routes using the routes property in MaterialApp:

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Named Routes Demo',
      initialRoute: '/', // Initial route (home page)
      routes: {
        // Mapping from route names to pages
        '/': (context) => const HomeScreen(),
        '/second': (context) => const SecondScreen(),
        '/detail': (context) => const DetailScreen(),
      },
    );
  }
}

2. Navigating with Named Routes

Use the Navigator.pushNamed() method to navigate using route names:

// Navigate from home page to second page
ElevatedButton(
  onPressed: () {
    Navigator.pushNamed(context, '/second');
  },
  child: const Text('Go to Second Screen'),
)

// Return to home page from second page
ElevatedButton(
  onPressed: () {
    Navigator.pop(context);
    // Or navigate to a specific route (replaces current route stack)
    // Navigator.pushReplacementNamed(context, '/');
  },
  child: const Text('Go Back'),
)

Common named route navigation methods:

  • Navigator.pushNamed(context, ‘/routeName’): Navigates to the specified route
  • Navigator.popAndPushNamed(context, ‘/routeName’): Pops current route and navigates to new route
  • Navigator.pushReplacementNamed(context, ‘/routeName’): Replaces current route
  • Navigator.pushNamedAndRemoveUntil(context, ‘/routeName’, (route) => false): Navigates to new route and removes all previous routes

III. Route Parameter Passing and Receiving

When navigating between pages, it’s often necessary to pass parameters (like IDs, names, etc.). Flutter provides multiple ways to pass and receive parameters between routes.

1. Basic Parameter Passing

Use the arguments parameter of Navigator.pushNamed() to pass data:

// Pass parameters
ElevatedButton(
  onPressed: () {
    Navigator.pushNamed(
      context,
      '/detail',
      arguments: {
        'id': 123,
        'title': 'Flutter Routing Tutorial',
      },
    );
  },
  child: const Text('View Details'),
)

In the target page, receive parameters using ModalRoute.of(context)!.settings.arguments:

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

  @override
  Widget build(BuildContext context) {
    // Receive parameters
    final Map<String, dynamic> args = 
        ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;

    return Scaffold(
      appBar: AppBar(title: Text(args['title'])),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('ID: ${args['id']}'),
            const SizedBox(height: 16),
            Text('Title: ${args['title']}'),
          ],
        ),
      ),
    );
  }
}

2. Type-safe Parameter Passing

To avoid type conversion errors, you can create dedicated parameter classes for type-safe parameter passing:

// Parameter class
class DetailArguments {
  final int id;
  final String title;

  DetailArguments({required this.id, required this.title});
}

// Pass parameters
ElevatedButton(
  onPressed: () {
    Navigator.pushNamed(
      context,
      '/detail',
      arguments: DetailArguments(
        id: 123,
        title: 'Flutter Routing Tutorial',
      ),
    );
  },
  child: const Text('View Details'),
)

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

  @override
  Widget build(BuildContext context) {
    // Type-safe parameter reception
    final DetailArguments args = 
        ModalRoute.of(context)!.settings.arguments as DetailArguments;

    return Scaffold(
      appBar: AppBar(title: Text(args.title)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('ID: ${args.id}'),
            const SizedBox(height: 16),
            Text('Title: ${args.title}'),
          ],
        ),
      ),
    );
  }
}

IV. Returning Data with Navigator.pop(context, result)

Not only can you pass parameters when navigating forward, but you can also carry data when returning to the previous page, which is useful for scenarios like form submission and selectors.

1. Basic Usage of Returning Data

In the second page, return data using Navigator.pop(context, result):

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Select an Option')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                // Return data
                Navigator.pop(context, 'Option 1');
              },
              child: const Text('Option 1'),
            ),
            ElevatedButton(
              onPressed: () {
                // Return data
                Navigator.pop(context, 'Option 2');
              },
              child: const Text('Option 2'),
            ),
          ],
        ),
      ),
    );
  }
}

In the first page, retrieve the returned data using async/await:

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            // Wait for return result
            final result = await Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => const SelectionScreen(),
              ),
            );

            // Process return result
            if (result != null) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Selected: $result')),
              );
            }
          },
          child: const Text('Go to Selection Screen'),
        ),
      ),
    );
  }
}

2. Returning Data with Named Routes

You can also return data when using named routes:

// Navigate and wait for result
final result = await Navigator.pushNamed(context, '/selection');

// Return data
Navigator.pop(context, 'Selected Value');

V. Route Hooks

Flutter provides a route hook mechanism that allows interception and processing during route navigation, enabling more flexible route management.

1. onGenerateRoute

onGenerateRoute is used for dynamically generating routes. When no matching route name is found in routes, this method is called. It’s suitable for scenarios where routes need to be dynamically generated or uniformly processed (like parameter validation, permission checks, etc.).

MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => const HomeScreen(),
    '/second': (context) => const SecondScreen(),
  },
  // Route generator
  onGenerateRoute: (settings) {
    // Process based on route name
    if (settings.name == '/detail') {
      // Validate parameters
      final args = settings.arguments;
      if (args is DetailArguments) {
        return MaterialPageRoute(
          builder: (context) => DetailScreen(args: args),
        );
      } else {
        // Navigate to error page for invalid parameters
        return MaterialPageRoute(
          builder: (context) => const ErrorScreen(message: 'Invalid arguments'),
        );
      }
    }

    // Other unmatched routes
    return null;
  },
)

Refactoring the detail page with onGenerateRoute:

class DetailScreen extends StatelessWidget {
  final DetailArguments args;

  const DetailScreen({
    super.key,
    required this.args,
  });

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

2. onUnknownRoute

When neither routes nor onGenerateRoute can match a route name, onUnknownRoute is called. It’s typically used for handling 404 cases, displaying a “page not found” message.

MaterialApp(
  // ...other configurations
  onUnknownRoute: (settings) {
    return MaterialPageRoute(
      builder: (context) => NotFoundScreen(
        routeName: settings.name,
      ),
    );
  },
)

// Not found page
class NotFoundScreen extends StatelessWidget {
  final String? routeName;

  const NotFoundScreen({
    super.key,
    this.routeName,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page Not Found')),
      body: Center(
        child: Text('The page "$routeName" does not exist.'),
      ),
    );
  }
}

3. Route Interception and Permission Control

onGenerateRoute can be used to implement route interception and permission control, such as checking if a user is logged in:

onGenerateRoute: (settings) {
  // List of routes requiring login
  final requiredAuthRoutes = ['/profile', '/settings'];

  // Check if login is required and user is not logged in
  if (requiredAuthRoutes.contains(settings.name) && !isLoggedIn()) {
    // Navigate to login page and record target route
    return MaterialPageRoute(
      builder: (context) => LoginScreen(
        redirectRoute: settings.name,
      ),
    );
  }

  // Process other routes normally
  // ...
}

VI. Advanced Route Management

For large applications, it’s recommended to use route management libraries (like auto_route, fluro, etc.) to simplify route management. However, understanding Flutter’s native routing mechanism remains important even when using these libraries.

1. Route Aliases and Modularity

In large applications, routes can be divided by modules and managed uniformly using route aliases:

// Route constant class
class AppRoutes {
  static const String home = '/';
  static const String login = '/auth/login';
  static const String register = '/auth/register';
  static const String profile = '/user/profile';
  static const String settings = '/user/settings';
  static const String productList = '/products';
  static const String productDetail = '/products/detail';
}

// When using
Navigator.pushNamed(context, AppRoutes.productDetail);

2. Custom Route Animations

In addition to default transition animations, you can customize route transition animations:

class CustomPageRoute<T> extends PageRoute<T> {
  final Widget child;

  CustomPageRoute({required this.child});

  @override
  Color? get barrierColor => null;

  @override
  String? get barrierLabel => null;

  @override
  bool get maintainState => true;

  @override
  Duration get transitionDuration => const Duration(milliseconds: 300);

  @override
  Widget buildPage(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
    // Custom animation
    return FadeTransition(
      opacity: animation,
      child: ScaleTransition(
        scale: animation,
        child: child,
      ),
    );
  }
}

// Using custom route
Navigator.push(
  context,
  CustomPageRoute(child: const DetailScreen()),
);

VII. Example: Complete Route Management Application

Here’s a complete application with multiple pages, parameter passing, and data return functionality:

// 1. Route constants
class Routes {
  static const String home = '/';
  static const String userList = '/users';
  static const String userDetail = '/users/detail';
  static const String settings = '/settings';
}

// 2. User model
class User {
  final int id;
  final String name;
  final String email;

  User({
    required this.id,
    required this.name,
    required this.email,
  });
}

// 3. Main application
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Route Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      initialRoute: Routes.home,
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case Routes.home:
            return MaterialPageRoute(builder: (context) => const HomePage());
          case Routes.userList:
            return MaterialPageRoute(builder: (context) => const UserListPage());
          case Routes.userDetail:
            final user = settings.arguments as User;
            return MaterialPageRoute(
              builder: (context) => UserDetailPage(user: user),
            );
          case Routes.settings:
            return MaterialPageRoute(builder: (context) => const SettingsPage());
          default:
            return MaterialPageRoute(
              builder: (context) => NotFoundPage(route: settings.name),
            );
        }
      },
    );
  }
}

// 4. Home page
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          ElevatedButton(
            onPressed: () => Navigator.pushNamed(context, Routes.userList),
            child: const Text('View User List'),
          ),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () async {
              final result = await Navigator.pushNamed(context, Routes.settings);
              if (result == true) {
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('Settings saved')),
                );
              }
            },
            child: const Text('Go to Settings'),
          ),
        ],
      ),
    );
  }
}

// 5. User list page
class UserListPage extends StatelessWidget {
  const UserListPage({super.key});

  // Simulated user data
  final List<User> users = const [
    User(id: 1, name: 'John Doe', email: 'john@example.com'),
    User(id: 2, name: 'Jane Smith', email: 'jane@example.com'),
    User(id: 3, name: 'Bob Johnson', email: 'bob@example.com'),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('User List')),
      body: ListView.builder(
        itemCount: users.length,
        itemBuilder: (context, index) {
          final user = users[index];
          return ListTile(
            title: Text(user.name),
            subtitle: Text(user.email),
            onTap: () {
              Navigator.pushNamed(
                context,
                Routes.userDetail,
                arguments: user,
              );
            },
          );
        },
      ),
    );
  }
}

// 6. User detail page
class UserDetailPage extends StatelessWidget {
  final User user;

  const UserDetailPage({
    super.key,
    required this.user,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(user.name)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('ID: ${user.id}'),
            const SizedBox(height: 8),
            Text('Name: ${user.name}'),
            const SizedBox(height: 8),
            Text('Email: ${user.email}'),
          ],
        ),
      ),
    );
  }
}

// 7. Settings page
class SettingsPage extends StatefulWidget {
  const SettingsPage({super.key});

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  bool _notificationsEnabled = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        children: [
          SwitchListTile(
            title: const Text('Enable Notifications'),
            value: _notificationsEnabled,
            onChanged: (value) {
              setState(() {
                _notificationsEnabled = value;
              });
            },
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: ElevatedButton(
              onPressed: () {
                // Return success result
                Navigator.pop(context, true);
              },
              child: const Text('Save Settings'),
            ),
          ),
        ],
      ),
    );
  }
}

// 8. Not found page
class NotFoundPage extends StatelessWidget {
  final String? route;

  const NotFoundPage({
    super.key,
    this.route,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page Not Found')),
      body: Center(
        child: Text('The page "$route" does not exist.'),
      ),
    );
  }
}


This content originally appeared on DEV Community and was authored by Ge Ji