So far in our adventures, we have yet to build more than a simple application, however in real life, most of the applications that we use in our daily lives have more than one page. Due to this, every framework has a way of navigating between different views or pages; this feature allows us to keep the user’s experience simple and intuitive because we only display the information that they need to see rather than everything that might lead to information overload.
In this chapter, we will learn how to navigate in a multi-page application using Flutter and its Navigator APIs. First, we will examine how navigation works in Flutter and how we can move from one screen of an application to another. Then, we will build a news application with the Navigator 1.0 API. After getting a clear understanding of how Navigator 1.0 works, we will refactor our application to use Navigator 2.0. Finally, we will simplify our application by using a popular routing library.
We will cover the following topics:
Make sure to have your Flutter environment updated to the latest version in the stable channel. Clone our repository and use your favorite IDE to open the Flutter project we’ve built at chapter_5/step_0_start.
The project that we’ll build upon in this chapter can be found on GitHub: https://github.com/PacktPublishing/Flutter-UI-Projects-for-iOS-Android-and-Web/tree/main/chapter_5/step_0_start.
The complete source code can be found on GitHub as well: https://github.com/PacktPublishing/Cross-Platform-UIs-with-Flutter/tree/main/chapter_5/step_4_end.
Let’s set up the project so we can start to explore how navigation works in a multi-page Flutter application.
In this chapter, we will learn how to use Flutter’s navigation APIs to build an interactive, multi-page Hacker News application that will allow us to navigate from lists of articles or stories, to their detail pages. Hacker News is a social news website focusing on computer science and entrepreneurship.
When we have finished, the resulting application should mirror Figure 5.1:
Figure 5.1 – Hacker News app
In Figure 5.1, we see three different screens:
Additionally, when tapping on an already selected tab from either the details or the story screen, we should be redirected back to the list view for that Story Type.
We will start with an example that has one empty page with tabs that match the Hacker News web application at https://news.ycombinator.com: New, Past, Comments, Ask, Show, and Jobs. This example also includes the controllers and APIs to communicate with the Hacker News API located at https://hacker-news.firebaseio.com.
After downloading the initial application from GitHub, we can start by moving into its root directory and running the flutter pub get command from the Terminal. This will install any package dependencies that the application needs. Additionally, opening the project in an IDE will also trigger an install of the project on initial load and when pubspec.yaml is updated.
After installing the application’s dependencies, execute the flutter run command from the same terminal window and view the project either in your browser or on your currently running emulator. When viewing the application in your IDE, you should observe the following file structure:
lib |-- src: |-- data |-- localization |-- ui |-- story |-- app.dart |-- main.dart
Let’s briefly explore the purpose of each top-level folder/class:
Now let’s explore the features of the Flutter framework that enable us to build platform-specific UIs.
Flutter has two ways of navigating between views. The first pattern introduced was an imperative API that makes use of the Navigator widget. Think of Navigator as a stack that sits at the top of the application. A stack is a linear data structure that follows the principle of Last In First Out (LIFO), meaning the last element inserted inside the stack is removed first. Stacks generally have two main functions: push which allows you to add a new item to the stack, and pop, which removes the item from the stack that was added. The Navigator widget extends this API further:
In this chapter, we will primarily be focused on pop and pushNamed. Like any other widget in Flutter, Navigator can be deeply nested inside of other navigators, and when you make an API call, it will only affect the Navigator widget most closely associated with the given context.
To tell Flutter how to define the onGenerateRoute property on MaterialApp. If you inspect app.dart, you will notice that we have already done this:
onGenerateRoute: (RouteSettings routeSettings) { return MaterialPageRoute<void>( settings: routeSettings, builder: (BuildContext context) { switch (routeSettings.name) { case StoryDetailsView.routeName: return const StoryDetailsView( storyId: 0, ); case StoryListView.routeName: default: return const StoryListView(); } }, ); },
The previous code snippet does the following:
If we run our application, we should get an image that matches the following:
Figure 5.2 – Story list view
Notice in Figure 5.2 that we have no way of getting to the StoryDetailsView. Let’s add that ability by opening StoryListView and adding the following block of code to the Column’s children:
TextButton( onPressed: () { Navigator.of(context).pushNamed( StoryDetailsView.routeName); }, child: const Text('Go to details'), )
Let’s examine what this code does:
Now if we reload the application, we should get our newly created button below the Story List text:
Figure 5.3 – Story list view with navigation button
If we click on our newly added button, as displayed in Figure 5.3, we should be redirected to the Story Details page and see the following screen:
Figure 5.4 – Story details view
One important thing to notice in Figure 5.4 is that we have no way of navigating back to the page that we came from. Let’s add a button to return to our previous page by extending StoryDetailsView with the following block of code to the Column’s children:
TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Go back'), ),
Let’s examine what this code does:
Upon refreshing the application, we should see our newly added Go back button. Our Story Details view should mirror the following screen:
Figure 5.5 – Story Details view with Go back button
Graphically speaking, the example in Figure 5.5 is fine, but technically, always displaying a back button on a screen can create a bad user experience. If somehow the user navigates to this screen initially, there would be no previous route to navigate to, and clicking on the button could cause an error.
Fortunately, the Navigator.canPop function allows us to verify that the stack has a previous page to navigate to. Let’s wrap our button with a conditional check using the Navigator.canPop function using the following code:
if (Navigator.canPop(context)) TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('Go back'), ),
In this refactored code snippet, we instruct the application to only display the back button if Navigator.canPop is true, signifying that there are additional routes on the stack beyond the current route.
You may be thinking that handling back navigation seems like an unnecessary step, and you would be correct. Flutter already has the ability to automatically imply whether or not to display a back button. This capability is built into AppBar, which will determine whether or not to display a back button if the leading property is empty and the automaticallyImplyLeading property is true.
Let’s add AppBar to both StoryListView and StoryDetailsView to see what this looks like. Extend the Scaffold widget in both classes with the following code before body:
appBar: AppBar(),
The preceding code merely tells the application that we would like to use Flutter’s Material AppBar with all of its default settings. These defaults include a leading property being empty and automaticallyImplyLeading being set to true. If we refresh our application, we should get the following results:
Figure 5.6 – Story Details view with Go back button
Notice in Figure 5.6 that the top area of the application now includes a short bar that is empty on the StoryListView, and has a back button signified by a left arrow icon on StoryDetailView. The AppBar widget automatically injects that widget based on the navigator stack.
Now we should have a clearer understanding of the setup that is required when using Flutter’s Navigator. We should also be a bit more comfortable using some of the imperative Navigator’s APIs to navigate between pages of an application. Next, let’s build our Hacker News application using Flutter’s Navigator 1.0 APIs.
Now that we have familiarized ourselves with how to do multi-page navigation in Flutter, let’s put what we’ve learned into action by building our Hacker News Application using these APIs. Flutter has a collection of APIs for handling multi-page navigation. The legacy APIs are referred to as Navigator 1.0. Let’s start by using those to build our page navigation.
We will start by adding a new view with tabs. Create a new home_view.dart class in the lib/src/ui/home folder. In it create a new StatefulWidget called HomeView and add the following logic to the _HomeViewState class’ build method:
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Hacker News'), bottom: TabBar( controller: _controller, tabs: [ for (final storiesType in StoriesType.values) Tab(text: storiesType.displayName) ], ), ), body: TabBarView( controller: _controller, children: [ for (final storiesType in StoriesType.values) StoryListView( storiesType: storiesType, ), ], ), ); }
In the previous code snippet, we have done the following:
Let’s do so now by adding the following code in the same _HomeViewState class before the build method:
late final TabController _controller; @override void initState() { super.initState(); _controller = TabController( length: StoriesType.values.length, vsync: this, ); } @override void dispose() { _controller.dispose(); super.dispose(); }
Now, we have done the following:
Re-run the application and observe changes, which should match the following screen:
Figure 5.7 – Home view with tabs
Figure 5.7 shows us our newly created tabs with the pre-existing StoryListView. Now let’s populate StoryListView with some actual data. Navigate to the widget and convert it from a StatelessWidget to a StatefulWidget. Replace the Selector.builder function with the following code:
return ListView.builder( itemCount: state.length, itemBuilder: (BuildContext context, int index) { final story = stories[index]; return ListTile( title: Text( story.title, ), subtitle: GestureDetector( onTap: () => _navigateToStoryComments(story.id), child: Text('${story.descendants} comments'), ), trailing: IconButton( onPressed: () => _navigateToWebview(story.id), icon: const Icon(Icons.exit_to_app), ), ); }, );
The previous code will use the stories provided by the Selector widget and build a list from those results. Additionally, we need to add the call to getStories to load the stories from the API.
Next, modify the initState method and replace it with the following code:
@override void initState() { super.initState(); _storyController = StoryController(context.read()) ..getStories(widget.storiesType); }
In this code, we immediately call the getStories method after initializing our controller. Finally, to run the application let’s add some empty methods for _navigateToStoryComments and _navigateToWebview, which are referenced in the build function. Add the following code after the build method:
void _navigateToStoryComments(int storyId) { } void _navigateToWebview(int storyId) { }
We will revisit these methods, but for now leave them empty. Now, run the application and you should observe the following:
Figure 5.8 – Home view with tabs and stories
Figure 5.8 shows that our application is finally displaying results. Congratulations! As you navigate between tabs and back, you may notice that the tabs must reload with every re-entry. This is less than ideal, as we would prefer the results to be persisted.
Let’s update our application to persist the state. Add the AutomaticKeepAliveClientMixin mixin to _StoryListViewState and then copy the following code before the initState method:
@override bool get wantKeepAlive => true;
AutomaticKeepAliveClientMixin will prevent our widget from being disposed of automatically, as long as wantKeepAlive is true. Additionally, we need to add super.build() as the first line in our build method. Now when running the application, you should be rewarded with tabs that persist in their state upon re-entry.
Next, let’s change StoryDetailsView to display WebView which points to the details page URL that the API gives us for each story. Replace the build method of StoryDetailsView with the following code:
return Scaffold( appBar: AppBar(), body: WebView( javascriptMode: JavascriptMode.unrestricted, initialUrl: 'https://news.ycombinator.com/item?id=$storyId', gestureRecognizers: <Factory<VerticalDragGestureRecognizer>>{ Factory<VerticalDragGestureRecognizer>( () => VerticalDragGestureRecognizer(), ) }, ), );
In this code snippet, we have swapped the previous body for a WebView that points to ‘https://news.ycombinator.com/item?id=$storyId’, which is the details website for the current story. We also pass in a VerticalDragGestureRecognizer to ensure smooth scrolling, since we are using WebView. Finally, we allow WebView to exercise JavaScript in an unrestricted mode to prevent any rendering errors that might occur.
Next, we would like to add a class to capture the storyId that we will pass to the StoryDetailsView widget. At the bottom of the file, add the following code:
class StoryDetailsViewArguments { const StoryDetailsViewArguments(this.storyId); final int storyId; }
Here, we have created a class called StoryDetailsViewArguments that takes in a single storyId parameter, which we will use to build the URL. We can now revisit story_list_view.dart and update the _navigateToStoryComments function to navigate to our details view using these arguments. Replace the function with the following code:
void _navigateToStoryComments(int storyId) { Navigator.of(context).pushNamed( StoryDetailsView.routeName, arguments: StoryDetailsViewArguments(storyId), ); }
This function will merely redirect to the story details, adding the arguments property with the StoryDetailsViewArguments that we just created.
As a final step, we need to refactor app.dart to make use of the new arguments class. Inside of MaterialPageRoute.builder replace the first switch case with the following code:
case StoryDetailsView.routeName: assert(routeSettings.arguments != null); final arguments = routeSettings.arguments as StoryDetailsViewArguments; return StoryDetailsView( storyId: arguments.storyId, );
The changes that we introduce are as follows:
Now, we should be able to run the application and see our details screen when tapping on the comments widget:
Figure 5.9 – Story details view
Figure 5.9 shows us the results that we should expect when navigating to the story details view: an empty AppBar and a WebView that directs us to the details of the story.
Now, let’s repeat the process to add a WebView for the story’s source URL. Create a file called story_web_view.dart in the story folder. In it, create a StatefulWidget called StoryWebView. Add the following code to the body of that class:
class StoryWebView extends StatefulWidget { const StoryWebView({ Key? key, required this.storyId, }) : super(key: key); final int storyId; static const routeName = '/story-source'; @override State<StoryWebView> createState() => _StoryWebViewState(); }
Like StoryDetailsView this widget takes in a storyId parameter and includes routeName for use with Navigator. Inside the _StoryWebViewState class replace the build method with the following:
@override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: _storyController, child: Scaffold( appBar: AppBar(), body: Selector<StoryController, Story?>( selector: (context, controller) => controller.selectedStory, builder: (context, story, child) { if (story == null) { return const Center( child: CircularProgressIndicator(), ); } if (story.url == null) { return const Center( child: Text('This story does not have a link'), ); } return WebView( javascriptMode: JavascriptMode.unrestricted, initialUrl: story.url.toString(), gestureRecognizers: <Factory<VerticalDragGestureRecognizer>>{ Factory<VerticalDragGestureRecognizer>( () => VerticalDragGestureRecognizer(), ) }, ); }, ), ), ); }
In this code, we do the following:
We have not added the logic to retrieve our _storyController or load the story, so we will do that next by adding the following code preceding the build method:
late final StoryController _storyController; @override void initState() { super.initState(); _storyController = StoryController(context.read()) ..getStoryById(widget.storyId); }
Now, we just need to round out this page by adding an arguments class to the bottom of the file, similar to StoryDetailsViewArguments.
class StoryWebViewArguments { StoryWebViewArguments(this.storyId); final int storyId; }
Revisit story_list_view.dart and update the _navigateToWebview function to navigate to our source web view using these arguments. Replace the function with the following code:
void _navigateToWebview(Story story) { Navigator.of(context).pushNamed( StoryWebView.routeName, arguments: StoryWebViewArguments(story.id), ); }
This function will merely redirect to the story source view, adding the arguments property with StoryWebViewArguments that we just created.
Finally, we just need to refactor app.dart to make use of the new view and new arguments class. Inside MaterialPageRoute.builder add the following code after the first switch case:
case StoryWebView.routeName: assert(routeSettings.arguments != null); final arguments = routeSettings.arguments as StoryWebViewArguments; return StoryWebView( storyId: arguments.storyId, );
The changes that we introduce are as follows:
Now, re-run the application and observe changes, which should lead to the following screen:
Figure 5.10 – Story source view
As you can see from Figure 5.10, we now have a view of the story’s source URL that matches our details view!
In this section, we finally put our newfound knowledge of Navigator to good use, building out our Hacker News application and demonstrating the following:
In the next section, we will discover how to build declarative applications with Navigator 2.0 and Google’s GoRouter library.
Now that we have learned how to build our Hacker News application using Flutter’s legacy navigation APIs, we can refactor our Hacker News Application to use Flutter’s declarative Navigator 2.0 APIs. Specifically, we will look at how to use a newer library provided and maintained by the Flutter team called go_router. Navigator 2.0 was introduced to provide a more powerful, declarative API that would empower developers who suffered from some of the shortcomings of Navigator 1.0, specifically the following:
The Navigator 2.0 spec, while more powerful is a more low-level API that is missing many of Navigator 1.0’s capabilities, such as pushReplacement or pushNamed. Fortunately, Google actively maintains a library that abstracts that complexity called GoRouter. In this section, we’ll learn the following:
When setting up routing using GoRouter, there are two classes that you will rely on, GoRouter and GoRoute. Let’s start using them to understand how they work. Create a file called app_router.dart in your src directory and add the following code:
final appRouter = GoRouter( routes: [ GoRoute( path: '/', builder: (context, state) => const Material( child: Center( child: Text('Hello GoRouter'), ), ), ), ], );
We have created a simple variable called appRouter using the GoRouter class, which does the following:
GoRouter is an important class that abstracts the complexity of Navigator 2.0 by automatically creating two classes that it relies on:
GoRoute declaratively maps URL paths to widgets via the builder method. As we will discover later, it additionally handles route nesting along with redirects.
Next, we need to replace our old MaterialApp with one that relies on our appRouter. Inside of the app.dart file, add the dependency on app_router to the top of the file and replace MaterialApp with the following code:
return MaterialApp.router( restorationScopeId: 'app', localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [ Locale('en', ''), ], onGenerateTitle: (BuildContext context) => AppLocalizations.of(context)!.appTitle, theme: ThemeData(), darkTheme: ThemeData.dark(), themeMode: ThemeMode.dark, routeInformationParser: appRouter.routeInformationParser, routerDelegate: appRouter.routerDelegate, );
In this code, we have the following:
Now if we re-run our application, it should match the following screen:
Figure 5.11 – Story source view
From Figure 5.11, you should see that the single route that we added previously is displayed. Now, let’s extend our example a bit further to demonstrate routing between pages. Replace the routes that we have defined with the following code:
GoRoute( path: '/', builder: (context, state) => Material( child: Center( child: TextButton( onPressed: () => appRouter.push('/page-two'), child: const Text('Go to Page 2'), ), ), ), ), GoRoute( path: '/page-two', builder: (context, state) => Material( child: Center( child: TextButton( onPressed: () => appRouter.go('/'), child: const Text('Go to Page 1'), ), ), ), ),
The code that we have added should define two routes that navigate back and forth between each other using two different methods:
Now, run the application and observe the following results when clicking back and forth between the buttons:
Figure 5.12 – Story source view
Figure 5.12 shows us the results of running the application and then tapping on the Go to Page 2 button, which redirects us to the page-two route.
The most powerful feature that we have introduced with these changes is web support. Run the application using flutter run -d chrome and you should observe the following when navigating directly to #/page-two:
Figure 5.13 – Story source view
From Figure 5.13, you can see that the correct page has been displayed without any extra effort on our part. Using Navigator 2.0 in the browser with URLs just works.
These capabilities are all the basics that we need to understand about the go_router library. Now that we understand how to use Navigator 2.0, we can refactor our Hacker News application to take advantage of more complex scenarios with the popular go_router library
Now that we have gotten familiar with how to use GoRouter we can begin to refactor the Hacker News application to take advantage of its features. First, we need to swap out our demo routes with the actual routes to our application. Inside of app_router.dart replace GoRouter.routes with the following:
GoRoute( path: '/', redirect: (_) => '/stories/${StoriesType.newStories.name}', ), GoRoute( path: '/stories/:storyType', builder: (context, state) { final storyTypeName = state.params['storyType'] ?? StoriesType.newStories.name; final storyType = StoriesType.values.byName(storyTypeName); return HomeView(key: state.pageKey, storyType: storyType); }, routes: [ GoRoute( path: 'story/:id/details', name: StoryDetailsView.routeName, builder: (context, state) { final id = int.parse(state.params['id']!); return StoryDetailsView(key: state.pageKey, storyId: id); }, ), GoRoute( path: 'story/:id/source', name: StoryWebView.routeName, builder: (context, state) { final id = int.parse(state.params['id']!); return StoryWebView(key: state.pageKey, storyId: id); }, ) ], ),
Make sure to resolve any dependency errors that are reported. This code should not look too dissimilar from our original onGenerateRoute, and does the following:
The :[parameter] keyword is a special pattern that enables us to include parameters in the route’s path. We can then access those parameters from the GoRouterState.params object passed into the builder function.
Next, we need to swap out instances where the application navigates using Navigator for instances using our appRouter. Find the _navigateToStoryComments and _navigateToWebview functions inside of story_list_view.dart and replace them with the following code:
void _navigateToStoryComments(int storyId) { appRouter.pushNamed(StoryDetailsView.routeName, params: {'storyType': widget.storiesType.name, 'id': '${storyId}'}); } void _navigateToWebview(int storyId) { appRouter.pushNamed(StoryWebView.routeName, params: {'storyType': widget.storiesType.name, 'id': '${storyId}'}); }
Now, instead of using Navigator, we are using appRouter and passing the correct story type. If we run the application, we should be met with the result matching our previous example.
Figure 5.14 – Hacker News app
If the application matches Figure 5.14, we should see a full Hacker News application and be able to navigate back and forth between details views and source views.
Now that we have wrapped up our application, let’s review what we have learned.
In this chapter, we learned how to build multi-page applications using Navigator 1.0. Then, we build a more complex Hacker News application to demonstrate how to pass arguments from page to page and use those arguments to display information. Finally, we rewrote the application using GoRouter to take advantage of Navigator 2.0’s declarative APIs and ability to display pages in the browser, based on their URL.
By learning how to build multi-page applications, you now understand how to provide a more robust experience to users that will work both on mobile devices and in browsers.
In the next chapter, you will learn how to build an application that responds to user input with forms and input fields.