We can use the Navigator to navigate from one widget (screen) to another. The caller might want to wait for the result that is returned from that navigation. If we want to stick to the static type system, we might run into some trouble here when named routes are being used.
The problem
At the time of writing (2015-11-18), when using named routes via Navigator.pushNamed() in combination with type safety on the caller side, the compiler throws an error.
Let’s examine the possibilities we have when we want to navigate from one screen to another and observe the issues we are facing.
Using push()
Starting with push()
, we consider this example to illustrate the problem:
1class MyApp extends StatelessWidget {
2 @override
3 Widget build(BuildContext context) {
4 return MaterialApp(
5 title: 'My App',
6 theme: ThemeData(
7 primarySwatch: Colors.lightBlue,
8 visualDensity: VisualDensity.adaptivePlatformDensity,
9 ),
10 home: TestScreen(),
11 );
12 }
13}
14
15class TestScreen extends StatelessWidget {
16 @override
17 Widget build(BuildContext context) {
18 return Scaffold(
19 appBar: AppBar(
20 title: Text('First'),
21 ),
22 body: Center(
23 child: TextButton(
24 child: Text('NEXT'),
25 onPressed: () async {
26 final bool? result = await Navigator.push<bool>(
27 context,
28 MaterialPageRoute(
29 builder: (BuildContext context) {
30 return TestScreen2();
31 },
32 ),
33 );
34
35 print(result);
36 },
37 ),
38 ),
39 );
40 }
41}
42
43class TestScreen2 extends StatelessWidget {
44 @override
45 Widget build(BuildContext context) {
46 return Scaffold(
47 appBar: AppBar(
48 title: Text('Second'),
49 ),
50 body: Center(
51 child: TextButton(
52 child: Text('PREVIOUS'),
53 onPressed: () {
54 Navigator.pop(context, true);
55 },
56 ),
57 ),
58 );
59 }
60}
Basically, we have two screens here: one screen showing a centered button with a label “NEXT”. When the user taps the button, we navigate to the second screen directly using push() and MaterialPageRoute inside the builder
function. Important here: we define the return type by specifying <bool>
. The result is stored in a variable of type bool?
because the signature requires an optional value. That’s because the user could also just navigate back using the app bar.
Using pushNamed()
Now let’s have a look at the equivalent approach using named routes:
1import 'package:flutter/material.dart';
2
3class MyAppNamed extends StatelessWidget {
4 @override
5 Widget build(BuildContext context) {
6 return MaterialApp(
7 title: 'My App',
8 theme: ThemeData(
9 primarySwatch: Colors.lightBlue,
10 visualDensity: VisualDensity.adaptivePlatformDensity,
11 ),
12 initialRoute: '/',
13 routes: {
14 '/': (context) => TestScreen(),
15 '/second': (context) => TestScreen2(),
16 },
17 );
18 }
19}
20
21class TestScreen extends StatelessWidget {
22 @override
23 Widget build(BuildContext context) {
24 return Scaffold(
25 appBar: AppBar(
26 title: Text('First'),
27 ),
28 body: Center(
29 child: TextButton(
30 child: Text('NEXT'),
31 onPressed: () async {
32 final bool? result = await Navigator.pushNamed<bool>(
33 context,
34 '/second',
35 );
36
37 print(result);
38 },
39 ),
40 ),
41 );
42 }
43}
44
45class TestScreen2 extends StatelessWidget {
46 @override
47 Widget build(BuildContext context) {
48 return Scaffold(
49 appBar: AppBar(
50 title: Text('Second'),
51 ),
52 body: Center(
53 child: TextButton(
54 child: Text('PREVIOUS'),
55 onPressed: () {
56 Navigator.pop(context, true);
57 },
58 ),
59 ),
60 );
61 }
62}
Instead of providing a value for the home
property of the MaterialApp
widget, we set the routes
: /
is the route for the first widget (TestScreen
), while /second
is the route for the second widget (TestScreen2
).
We also need to omit the home
property and instead set the initialRoute
as the MaterialApp
widget forbids both of the properties to bet set at the same time.
When the screen transition is about to happen (onPressed
), we use pushNamed()
instead of push()
in order to navigate to the next screen.
The error
Now the counter-intuitive part: this produces the following error:
1E/flutter ( 7570): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: type 'MaterialPageRoute<dynamic>' is not a subtype of type 'Route<bool>?' in type cast
2E/flutter ( 7570): #0 NavigatorState._routeNamed (package:flutter/src/widgets/navigator.dart:4186:57)
3E/flutter ( 7570): #1 NavigatorState.pushNamed (package:flutter/src/widgets/navigator.dart:4243:20)
4E/flutter ( 7570): #2 Navigator.pushNamed (package:flutter/src/widgets/navigator.dart:1742:34)
5...
It’s irritating that using generics to specify a return type for push()
works, but produces an error when using pushNamed()
. This is an error that’s already described in a Flutter GitHub issue.
One solution
There is a way around this: instead of providing the return type via generics, we can cast the return type to our desired bool
. So instead of this:
We can simply write this:
But casting does not seem so safe as it can lead to runtime errors if we cast to the wrong type. And the question remains: why is it even a problem to do it the like we did in the first approach?
The cause
When we use the routes
property in the MaterialApp
widget, there is something we implicitly decide along with that: that every route inside is a MaterialPageRoute<dynamic>
. The problem here is that this can not be converted to typed routes like Route<bool>
.
Using pushNamed()
is actually the same as using pushNamed<dynamic>
. The pushNamed
method of Navigator
looks like this:
1@optionalTypeArgs
2 Future<T?> pushNamed<T extends Object?>(
3 String routeName, {
4 Object? arguments,
5 }) {
6 return push<T>(_routeNamed<T>(routeName, arguments: arguments)!);
7 }
_routeNamed<T>
is the key here as it leads to further calls that assume the same type argument for the route that is returned.
In the end, Dart is trying to cast MaterialPageRoute<dynamic>
(defined by routes
property) to Route<bool?>
which is is problematic because at runtime, we can not be sure that dynamic
is something bool
is a subtype of.
A better solution
Flutter provides an alternative to using the routes
parameter: the onGenerateRoute
property:
1@override
2 Widget build(BuildContext context) {
3 return MaterialApp(
4 title: 'My App',
5 theme: ThemeData(
6 primarySwatch: Colors.lightBlue,
7 visualDensity: VisualDensity.adaptivePlatformDensity,
8 ),
9 initialRoute: '/',
10 routes: {
11 '/': (context) => TestScreen(),
12 },
13 onGenerateRoute: (RouteSettings settings) {
14 final String routeName = settings.name ?? '';
15
16 switch (routeName) {
17 case '/second':
18 return MaterialPageRoute<bool>(
19 builder: (BuildContext context) => TestScreen2(),
20 settings: settings,
21 );
22 }
23 },
24 );
25 }
Instead of defining the second route in the routes
parameter, we use onGenerateRoute
– a callback function with RouteSettings
as its only argument.
Now we can finally use the pushNamed()
the way it’s intended:
1onPressed: () async {
2 final bool? result = await Navigator.pushNamed<bool>(
3 context,
4 '/second',
5 );
6
7 print(result);
8},
Conclusion
It’s generally absolutely recommended to use named routes as a navigation concepts in every app that has more than a little complexity. If the routes
property of MaterialApp
, one has to be aware that static typing in combination with pushNamed
either has to be abandoned or forced by type cast. A better solution is to make us of onGenerateRoute
instead. It enables the developer to provide a type argument to pushNamed
and expect the return type to be of that exact type.
Comment this 🤌