Navigation in Flutter

    Summary

    In this lesson we'll cover:

    • Using ListView
    • Handling tap events with GestureDetector
    • Navigation and parameterized Named Routes with Navigator, MaterialPageRoute and RouteFactory

    The Code for This Lesson

    You can check out the step/step06 branch here: which will contain the code for this lesson.

    Updating Our Fixture Data

    In this lesson, we'll be showing a list of locations, so we'll need to add more to our list of fixture data.

    • Check out the code cited above and copy and paste the latest location.dart file.
    • Also copy the latest three images in assets/images/*.jpg.
    • Note that an id field has now been added so that we can load a LocationDetail screen by ID.
    • Example:
    // models/location.dart
    
    class Location {
      final int id;
    
      // ...
    
      static List<Location> fetchAll() {
        return [
          Location(1, 'Kiyomizu-dera', 'assets/images/kiyomizu-dera.jpg', [
            // ...
          ]),
          Location(2, 'Mount Fuji', 'assets/images/fuji.jpg', [
            // ...
          ]),
          Location(3, 'Arashiyama Bamboo Grove', 'assets/images/arashiyama.jpg', [
            // ...
          ]),
    
        // ...
    

    Implementing our Location Listing Screen

    • Create a new StatelessWidget that returns a Scaffold and a ListView inside.
    • We simply fetch a list of locations and iterate over them using map().
    • ListView will render a full screen, scrollable list of items.
    // locations/locations.dart
    
    import 'package:flutter/material.dart';
    import '../../app.dart';
    import '../../models/location.dart';
    
    class Locations extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        // NOTE: we'll be moving this to a scoped_model later
        final locations = Location.fetchAll();
    
        return Scaffold(
          appBar: AppBar(
            title: Text('Locations'),
          ),
          body: ListView(
            children: locations
              .map((location) => Text(location.name))
              .toList(),
          ),
        );
      }
    
      // ...
    }
    

    WARNING: the above code snippet will not be our final implementation in this lesson.

    Implementing a GestureDetector for each ListView Item

    • Let's complete our implementation by adding a GestureDetector widget, which lets us tap on a child widget.
    • We'll add a Container as a child of each GestureDetector presenting the location name.
    • The onTap parameter for GestureDetector takes a Dart closure (covered in the previous lesson). We can then invoke our _onLocationTap function, passing in the ID of the location.
    • _onLocationTap will contain the implementation of our tap event (to be filled out at the end of the lesson).
    // locations/locations.dart
    
    import 'package:flutter/material.dart';
    import '../../app.dart';
    import '../../models/location.dart';
    
    class Locations extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        // NOTE: we'll be moving this to a scoped_model later
        final locations = Location.fetchAll();
    
        return Scaffold(
          appBar: AppBar(
            title: Text('Locations'),
          ),
          body: ListView(
            children: locations
              .map((location) => GestureDetector(
                  onTap: () => _onLocationTap(context, location.id),
                  child: Container(child: Text(location.name))))
              .toList(),
          ),
        );
      }
    
      _onLocationTap(BuildContext context, int locationID) {
        // TODO later in this lesson, navigation!
        print('do something');
      }
    }
    

    Updating our LocationDetail to Take a Location ID Parameter

    • Let's make our Location Detail screen more realistic now.
    • We should be able to load this screen by passing in the unique "ID" of a location (which we set up in the first step of this lesson).
    • When the Location Detail screen loads, we can then fetch the full information for the location by ID:
    // location_detail/location_detail.dart
    
    // ...
    
    class LocationDetail extends StatelessWidget {
      final int _locationID;
    
      LocationDetail(this._locationID);
    
      @override
      Widget build(BuildContext context) {
        final location = Location.fetchByID(_locationID);
    
        // ...
    
    • Here we simply create a new private member and constructor to parameterize our StatelessWidget, just like we have in previous lessons.
    • Here's the final implementation of this screen now for this lesson:
    // location_detail/location_detail.dart
    
    import 'package:flutter/material.dart';
    import '../../models/location.dart';
    import 'image_banner.dart';
    import 'text_section.dart';
    
    class LocationDetail extends StatelessWidget {
      final int _locationID;
    
      LocationDetail(this._locationID);
    
      @override
      Widget build(BuildContext context) {
        // NOTE: we'll be moving this to a scoped_model later
        final location = Location.fetchByID(_locationID);
    
        return Scaffold(
          appBar: AppBar(
            title: Text(location.name),
          ),
          body: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              ImageBanner(location.imagePath),
            ]..addAll(textSections(location))),
        );
      }
    
      List<Widget> textSections(Location location) {
        return location.facts
          .map((fact) => TextSection(fact.title, fact.text))
          .toList();
      }
    }
    

    Now let's talk about navigation. We'll implement the bit of logic that handles the tap event we added in the previous step now.

    "Navigation" is simply the ability to move from screen to screen in a mobile or web app. A navigation "route" is effectively a name we define for where our screen lives, kind of like a URL.

    So for example, if we've implemented a screen implemented as widget Screen1 we can navigate to it via:

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

    NOTE this example was taken from

    • Navigator here is something offered by Flutter.
    • Its push() method is a simple way to present our screen.
    • MaterialPageRoute is the required widget to use when we're using the Material Design (via import 'package:flutter/material.dart';).
    • MaterialPageRoute wants us to "build" (i.e. implement) our own function which should return the widget we want to display.
    • The problem with this approach is that it's not very re-usable. If we have to present this screen in many places in our code, it's not very "DRY".

    Using Named Routes

    • Let's implement all of our routes in a centralized place, app.dart.
    • Our MaterialApp widget offer a routes parameter, allows us to associate a "name" with the instantion of the actual screen we want to present. More on this here.
    • For example:
    
    MaterialApp(
      // Start the app with the "/" named route. In our case, the app will start
      // on the FirstScreen Widget
      initialRoute: '/',
      routes: {
        // When we navigate to the "/" route, build the FirstScreen Widget
        '/': (context) => FirstScreen(),
        // When we navigate to the "/second" route, build the SecondScreen Widget
        '/second': (context) => SecondScreen(),
      },
    );
    

    We can then use the route and refer to it by name:

    
    Navigator.pushNamed(context, '/second');
    

    NOTE this example was taken from

    Passing Arguments to Named Routes

    • The problem with the above example is that we can't pass any information to the screen we'd want to load. So if say FirstScreen above takes an argument, it won't be possible.
    • The official documentation on using parameterized routes presents quite a poor solution IMO.
    • It requires us to create a separate widget just to pass an argument.
    • There's I think a better way to do this. We can use MaterialApp's onGenerateRoute parameter and a RouteFactory:
    // app.dart
    
    // ...
    
    MaterialApp(
      onGenerateRoute: _routes(),
    );
    
    // ...
    
    RouteFactory _routes() {
      return (settings) {
        final Map<String, dynamic> arguments = settings.arguments;
        Widget screen;
        switch (settings.name) {
          case LocationsRoute:
            screen = Locations();
            break;
          case LocationDetailRoute:
            screen = LocationDetail(arguments['id']);
            break;
          default:
            return null;
        }
        return MaterialPageRoute(builder: (BuildContext context) => screen);
      };
    }
    
    • Here, we pass a RouteFactory to onGenerateRoute.
    • A RouteFactory is defined by a Dart closure (an anonymous function), taking a RouteSettings param defined here by the settings variable
    • RouteSettings has a member called arguments which is a basic Dart Object
    • It also has a member called name which represents the name of the route.
    • The rest of the function defines how a given route is built based on the route name.

    Adding Our Navigation Event

    • We can now update our _onLocationTap method in locations.dart.
    • We use the Navigator.pushNamed() method and we can pass a simple Map of arguments:
    
    // ...
    
    _onLocationTap(BuildContext context, int locationID) {
      Navigator.pushNamed(context, LocationDetailRoute,
          arguments: {"id": locationID});
    }
    

    Here's the final locations.dart file:

    // locations/locations.dart
    
    import 'package:flutter/material.dart';
    import '../../app.dart';
    import '../../models/location.dart';
    
    class Locations extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        // NOTE: we'll be moving this to a scoped_model later
        final locations = Location.fetchAll();
    
        return Scaffold(
          appBar: AppBar(
            title: Text('Locations'),
          ),
          body: ListView(
            children: locations
                .map((location) => GestureDetector(
                    onTap: () => _onLocationTap(context, location.id),
                    child: Container(child: Text(location.name))))
                .toList(),
          ),
        );
      }
    
      _onLocationTap(BuildContext context, int locationID) {
        Navigator.pushNamed(context, LocationDetailRoute,
            arguments: {"id": locationID});
      }
    }
    

    Here's the final app.dart file:

    // app.dart
    
    import 'package:flutter/material.dart';
    import 'screens/locations/locations.dart';
    import 'screens/location_detail/location_detail.dart';
    import 'style.dart';
    
    const LocationsRoute = '/';
    const LocationDetailRoute = '/location_detail';
    
    class App extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          onGenerateRoute: _routes(),
          theme: _theme(),
        );
      }
    
      RouteFactory _routes() {
        return (settings) {
          final Map<String, dynamic> arguments = settings.arguments;
          Widget screen;
          switch (settings.name) {
            case LocationsRoute:
              screen = Locations();
              break;
            case LocationDetailRoute:
              screen = LocationDetail(arguments['id']);
              break;
            default:
              return null;
          }
          return MaterialPageRoute(builder: (BuildContext context) => screen);
        };
      }
    
      ThemeData _theme() {
        return ThemeData(
            appBarTheme: AppBarTheme(textTheme: TextTheme(title: AppBarTextStyle)),
            textTheme: TextTheme(
              title: TitleTextStyle,
              body1: Body1TextStyle,
            ));
      }
    }
    

    How to 'Go Back'

    • Because we're using an AppBar in our LocationDetail screen, a back button automatically appears for us.
    • If we want to define the ability to go back, we can use Navigator.pop(context); but this is not necessary in our case.

    Summary

    In this lesson we covered a realistic example of navigation as well as how to pass arguments to the screen we are navigating to. We've also avoided the official documentation's not-so-ideal "Pass Arguments to a Named Route" approach by using MaterialApp's onGenerateRoute.****