When the user navigates from one screen to another, he most likely eventually comes back after he has finished his task on the second screen. The action on his second screen could have affected the first screen, so we want to reload its data after he has popped the route. How do we do that? And can we decide – depending on his actions – if we want to refresh the first screen?
The use case
Let’s say we have an app that let’s you create, edit and manage notes. There is
- A “note overview” screen that lists all of your notes
- A “create note” screen that lets you create a new note
- An “edit note” screen that lets you edit a created note
Navigator.push and its return value
We assume that the user starts on the “note overview” screen and has the ability to navigate to “create note” and “edit note”.
Flutter’s navigation concept provides two possibilities to change the current route:
- Navigating via an anonymous route via push()
- Navigating via a named route via pushNamed()
In both cases, the return value of the method is a Future
. That means: we can wait for the result and react accordingly.
One side note, though: if you use pushNamed()
which is generally recommended as it leads to all routes being defined at one place and reduces code duplication, you might face a type error. Read this article for further details.
So we know how to navigate to a different route. But how do we trigger this Future
‘s completion? That’s where the opposite of push()
comes into play: pop().
The resulting navigation would look like this:
In terms of our sample note app, this would apply to the “edit note” route and the “create note” route. Assuming that our data source is remote, we would want to reload the overview once a new note is added or an existing note is edited.
Reacting to a popped route
To capture a Future’s return value, we have two options:
- Using then()
- Using async / await
That means we can either handle the second route being popped like this:
1await Navigator.pushNamed(
2 context,
3 '/note/details',
4 arguments: noteId,
5);
6
7_refreshData();
Or like this:
1Navigator.pushNamed(
2 context,
3 '/note/details',
4 arguments: noteId,
5).then((_) {
6 _refreshData()
7});
Either way is fine. I personally prefer the await
way because it prevents unnecessary nesting and is thus much more readable in my opinion.
What about canceling?
We still have one problem, though: if we keep it this way, we reload the first route independent of the second route’s cause to pop. The user could have just tapped the back button. We would know that in this case, nothing has changed, so there would be no reason to refresh. We’d refresh the first route anyways. This uses unnecessary computational power (and bandwidth).
Without any further involvement, the usual back button tap performs a Navigator.pop()
without any arguments. Good for us, because every other (intentional) pop can be fed with an argument telling the caller if something has changed (if a refresh is necessary). As a caller we can be certain then: if the return value of pop()
is null
or false
, nothing has changed so don’t refresh. Otherwise (if it’s true
), do refresh!
So the edit and create widgets would do something like this:
1Navigator.of(context).pop(true);
And the overview widget would react like this:
1final bool? shouldRefresh = await Navigator.pushNamed<bool>(
2 context,
3 '/note/details',
4 arguments: noteId,
5);
6
7if (shouldRefresh) {
8 _refreshData();
9}
How does it work with Dialogs and BottomSheets?
Let’s assume, we have the ability to apply certain actions to our notes directly on the overview screen by long tapping the respective entries.
Here we basically have the same situation: we want to wait for the user to interact with the BottomSheet and react depending on what his action was. In particular, we want to know, if the action the user has executed influences the note list which would require the application to refresh it in order to present an up-to-date list.
The good thing is that the API to show a BottomSheet
is quite similar to push()
. showModalBottomSheet has a type argument as well and returns a Future
being typed with the given type.
This is what the method call to open the BottomSheet
could look like:
1final bool? shouldRefresh = showModalBottomSheet<bool>(
2 context: context,
3 builder: (BuildContext context) {
4 return Padding(
5 padding: const EdgeInsets.all(24.0),
6 child: Column(
7 mainAxisSize: MainAxisSize.min,
8 children: <Widget>[
9 Text(
10 'Note options',
11 style: Theme.of(context).textTheme.headline5,
12 ),
13 SizedBox(
14 height: 16,
15 ),
16 ListTile(
17 leading: Icon(Icons.star_rounded),
18 title: Text('Mark as favorite'),
19 onTap: () {
20 await _setNoteAsMarked();
21 Navigator.pop(true);
22 },
23 ),
24 ListTile(
25 leading: Icon(Icons.delete_rounded),
26 title: Text('Delete note'),
27 onTap: () {
28 await _deleteNote();
29 Navigator.pop(true);
30 },
31 ),
32 ],
33 ),
34 );
35 },
36);
37
38if (shouldRefresh) {
39 _refreshData();
40}
Like in the above example with push
, we wait for the result and only refresh the list of the return value is true
(representing the necessity to refresh).
Important: In this code example, we use await
to wait for the results of the action. This is supposed to emphasize that we must only refresh the list once the server has responded (assuming we’re dealing with a web service here that manages our data). Otherwise, we refresh the list too early, before the new state has been persisted. When dealing with BLoC, we’d need to setup a BlocListener in order to listen for the moment when the API call was successful and then call Navigator.pop(true)
.
The way to deal with Dialogs is the same as showDialog has the same API as showModalBottomSheet
:
1final bool? shouldRefresh = showDialog<bool>(
2 context: context,
3 builder: (BuildContext context) {
4 return AlertDialog(
5 content: Column(
6 mainAxisSize: MainAxisSize.min,
7 children: <Widget>[
8 ListTile(
9 leading: Icon(Icons.star_rounded),
10 title: Text('Mark as favorite'),
11 onTap: () => null,
12 ),
13 ListTile(
14 leading: Icon(Icons.delete_rounded),
15 title: Text('Delete note'),
16 onTap: () => null,
17 ),
18 ],
19 ),
20 );
21 },
22);
23
24if (shouldRefresh) {
25 _refreshData();
26}
What if I have a nested navigation?
Let’s say the route you are navigating to, performs a navigation itself like this:
Can we apply the above mentioned pattern as well? Does it make things more complicated?
In fact, we have a call chain here: the first screen calls showModalBottomSheet()
and waits for its response. Inside the BottomSheet
, Navigator.pushNamed()
is called and waited for, which opens up the final route (edit note screen). When this route is popped (e. g. the submit button is tapped), the BottomSheet
receives a response (true
or false
) and forwards this response to Navigator.pop()
which eventually arrives at the initial caller.
That means the pattern does not change. Every route is supposed to act independently by returning a specific value to Navigator.pop()
being returned to the caller that navigated to the route. That’s a good thing because every route navigating to our target route can decide for itself how to react on the return values.
Sometimes, it’s also appropriate to return something more meaningful like an enum
instead of a bool
. This can be the case for a BottomSheet
we might want to react differently depending on the chosen option. So we provide an enum
to Navigator.pop()
in the BottomSheet
that gives the calling route the possibility to react depending on the value of this enum
.
On the diagram this might look more complicated, but on the code side, there is nothing much going on.
Conclusion
Basically reloading the original route when navigating to a second one is as simple as waiting for the result of the second route (using then()
or async / await
) which is determined by the argument given to pop()
. When using pushNamed
with a static type, there are things to consider.
When there is a call chain with more than two routes, the mechanic stays the same for every part of the chain: wait for the return value and react accordingly, which possibly includes forwarding the result.
Opening BottomSheet
s and Dialog
s behaves likewise.
When tapping the back button or dismissing BottomSheet
s and Dialog
s, the caller receives null
as the return value and can react conforming to that.
Daniel F
Marc
In reply to Daniel F's comment