One of the most important aspects of an app is the flow or journey that the user takes through the app. Apps are often described in terms of pages, or screens, and navigating between them. In this post I’m going to cover dividing your application into routes, and how to work with the Flutter navigation system.
Whilst a Flutter app is constructed out of widgets, there still needs to be a mechanism for the app to respond to user interaction. For example, tapping on an row in a list of items might display the details of that item. Subsequently tapping on the back button should return the user to the list of items. In order to support navigating between different pages, Flutter includes the Navigator class. According to the documentation the Navigator class is:
A widget that manages a set of child widgets with a stack discipline.
For someone who’s just got their head around how to layout widgets, this statement makes no sense. In this post we’re going to ignore this definition and cover the basics of how to navigate between pages. I’m also going to ignore the Flutter definition of a Route. For now we’ll assume that a route is roughly equivalent to a page or a screen in your app. As you can imagine, all but very basic apps have multiple pages that the user is able to navigate between. Flutter apps are no different except that you navigate Flutter apps with routes.
Flutter Navigation
Let’s get into navigating between routes. We’ll start with almost the most basic Flutter app you can create. Technically you can create a Flutter app that doesn’t start with the MaterialApp but then you’re left doing a lot of heavy lifting yourself. The following code sets up a basic app that is comprised of two classes that inherit from StatelessWidget. I could have combined these into a single widget by simply setting the value of the home attribute in the MaterialApp to be the new Container widget. However, I’ve created a separate widget, FirstNoRoutePage, to help make it easy to see how the pages of the app are defined.
void main() => runApp(MyNoRouteNavApp());
class MyNoRouteNavApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: FirstNoRoutePage(),
);
}
}
class FirstNoRoutePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: Text('First Page'),
);
}
}
Ad-Hoc Routes with Navigator.push()
Now that we have the basic app structure, it’s time to navigate to our first route. We’re going to use the Navigator widget to push a newly created, or ad-hoc, route onto the stack. The following code:
- Wraps the Text widget in a FlatButton
- The onPressed calls the push method on the Navigator, passing in a newly constructed MaterialPageRoute.
- The MaterialPageRoute builder is set to return a new instance of the SecondNoRoutePage widget
class FirstNoRoutePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: FlatButton(
child: Text(
'First Page',
style: TextStyle(color: Colors.white),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => SecondNoRoutePage(),
));
},
),
);
}
}
class SecondNoRoutePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: Text(
'Second Page',
style: TextStyle(color: Colors.white),
),
);
}
}
We now have an application that has two pages that the user can navigate between. On the first page, clicking on the “First Page” text will push a new route that shows the second page (i.e. the SecondNoRoutePage widget). Pressing the device back button (Android only) will navigate the user back to the first page. It does this by popping the route off the stack maintained by the Navigator.
Go Back with Navigator.pop()
For the purpose of this post I’ve kept the pages simple, showing only elements necessary to show which page the user is on, or to allow the user to perform an action. I’ve chosen not to use a Scaffold, which means no AppBar. This also means no back button shown in the AppBar. On iOS this makes it impossible for the user to navigate to the previous page as there is no dedicated back button, unlike Android. If you use a Scaffold in your widget, you’ll see the AppBar. The AppBar adjusts to include a back button if there’s more than one route on the stack.
Flutter provides built in support for navigating back to the previous route via the AppBar back button or, in the case of Android, the device back button. In addition, the pop method on the Navigator can be used to pop the current route off the stack. This will return the user to the previous route.
class SecondNoRoutePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: FlatButton(
child: Text(
'Second Page',
style: TextStyle(color: Colors.white),
),
onPressed: () {
Navigator.pop(context);
},
),
);
}
}
What the Current Route?
Earlier in the post we created a new route when navigating to the second page of the app. However, what we glossed over is the fact that the first page of the app is also a route. To show this, let’s add some code that adds the name of the current route to both pages. This will highlight where in the Flutter navigation stack the user is currently at.
class FirstNoRoutePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var route = ModalRoute.of(context).settings.name;
return Container(
alignment: Alignment.center,
child: FlatButton(
child: Text(
'First Page - $route',
style: TextStyle(color: Colors.white),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => SecondNoRoutePage(),
));
},
),
);
}
}
class SecondNoRoutePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var route = ModalRoute.of(context).settings.name;
return Container(
alignment: Alignment.center,
child: FlatButton(
child: Text(
'Second Page - $route',
style: TextStyle(color: Colors.white),
),
onPressed: () {
Navigator.pop(context);
},
),
);
}
}
What gets displayed on the two pages are ‘First Page – /’ and ‘Second Page – null’. This indicates that the first route is assigned a name of ‘/’, whilst the second route does not have a name (i.e. null value). The first route is created from the home property being set on the MaterialApp. It is assigned a name equal to the defaultRouteName constant from the Navigator class (i.e. ‘/’) to indicate it is the entry point for the application.
As we created the route for the second page, it’s our responsibility to name the route. In the above scenario we’re creating the route at the point where it is required (i.e. when navigating to the second page), and as such we don’t need to give it a name. That is, unless we want to see that the current route is by inspecting it’s name. Let’s update the call to Navigator.push to include a name for the second route.
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => SecondNoRoutePage(),
settings: RouteSettings(name: '/second')
));
},
There’s no requirement for the route name to include the ‘/’. However, it is a convention to do so, and there are some scenarios where the ‘/’ has some implied behaviour.
Named Routes in Flutter Navigation
In the previous section we added a name to the ad-hoc route that we created. An alternative to creating routes as they’re required, is to define the routes for the app up front. For example the following code shows three routes that have been defined for an app and can be used for Flutter navigation.
class Routes {
static const String firstPage = '/';
static const String secondPage = '/second';
static const String thirdPage = '/third';
}
class MyNavApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
routes: {
Routes.firstPage: (BuildContext context) => FirstPage(),
Routes.secondPage: (BuildContext context) => SecondPage(),
Routes.thirdPage: (BuildContext context) => ThirdPage(),
},
);
}
}
The Routes class includes the names of each of the three routes. Inside the constructor of the MaterialApp widget the three routes have been declared. Each route is an association between the route name and the builder method that’s used to construct the widget.
Navigate.pushNamed()
When the user clicks the button, the Navigate.pushNamed method is used to navigate to the corresponding route:
onPressed: () { Navigator.pushNamed(context, Routes.secondPage); },
Skipping Over Routes with Navigator.popUntil
Using named routes not only makes it easier to manage the pages in your app, it also means that you can create conditional logic that is dependent on the route name. For example, the Navigator.popUntil allows you to keep popping routes off the stack until the predicate is true. In the following code the predicate looks at the name property of the route to determine whether it is the first page of the app.
class ThirdPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: FlatButton(
child: Text(
'Third Page',
style: TextStyle(color: Colors.white),
),
onPressed: () {
Navigator.popUntil(context, (r) => r.settings.name == Routes.firstPage);
},
),
);
}
}
Setting the Initial Route
Initially the first page of the app was defined by setting the home property. This was used to define the initial route for the app. When the app was updated to use a set of predefined routes, the initial route was inferred by looking for the route where the name matches the defaultRouteName (i.e. ‘/’). If there wasn’t a route with that name isn’t found, an error will be raised and the app will fail to start.
It is also possible to set the initialRoute property on the MaterialApp. However, this doesn’t negate the need to set either the home property, or have a route declared with a name of ‘/’. What’s really interesting about the initialRoute property is that it can be used to launch the app on a route that has other routes in the navigation stack. For example in the following code the initialRoute is set to the thirdPage, which is defined as ‘/second/third’. When this app launches, it will launch showing ThirdPage but if the user taps the back button, they will go to SecondPage and then FirstPage if they tap again.
class Routes {
static const String firstPage = '/';
static const String secondPage = '/second';
static const String thirdPage = '/second/third';
}
class MyNavApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
initialRoute: Routes.thirdPage,
routes: {
Routes.firstPage: (BuildContext context) => FirstPage(),
Routes.secondPage: (BuildContext context) => SecondPage(),
Routes.thirdPage: (BuildContext context) => ThirdPage(),
},
);
}
}
On app startup the initalRoute is split on ‘/’ and then any routes that match the segments are navigated to. In this case the app navigates to FirstPage, then SecondPage and finally ThirdPage.
Intercepting Back Button
The last topic for this post looks at how to intercept the back button. This can be done by including the WillPopScope widget and returning an appropriate value in the onWillPop callback.
class ThirdPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () {
return new Future.value(true);
},
child: Container(
alignment: Alignment.center,
child: Text('Third Page'),
),
);
}
}
Note that the onWillPop expects a Future to be returned, allowing you to return an asynchronous result. In this case the code is simply returning the value of true to allow the current route to be popped.
Summary of Flutter Navigation
This post on Flutter navigation has covered a number of the navigation related methods on the Navigator class. As part of defining how your app looks, you should define all the routes and how the user will navigate between them. If you do this early in the development process you’ll be able to validate the flow of your app as you continue development.