Migration to go_router — dev’s story

Dudeck
12 min readJun 7, 2023

--

Introduction

Our client always wanted to have deeplinks provided by push notifications. In client’s app we developed navigation by using Navigator’s methods pop/push/pushReplacement etc. We even created simple deeplink handling with .popUntil() solution that allowed us to open specific tab from Bottom Navigation. But we decided to wait until Google will choose one of 3 libraries to help devs with implementation of Navigator 2.0 which supports deeplinks.

In the end of 2021 Google chose 3 libraries to support Navigator 2.0:
beamer, vroute, autoroute and made deep research which one should be supported:

https://github.com/flutter/uxr/tree/master/nav2-usability/comparative-analysis

But then suddenly they changed mind and took go_router as officially supported package by them.

There are many disappointed devs that even created some reddit threads to discuss this subject:

So should you migrate to go router? I will not answer for you for that question, but I will try to show you why we decided to do that and how we handled cases where go_router made us trouble. Let’s start!

Basics/Research

Like always before doing anything, we decided to make a research. Our Architect asked go_router’s team directly about our app use cases and we decided that we will wait until go_router 5.0 will be available which solved all basic problems. So we started with version 5.0.0 but before we added any code to client app we developed PoC project to test all cases if they are possible to handle. The main cases were:

1. Deeplink support
go_router supports deeplinks out of the box, we should only enable them on Android/iOS following the instructions:
https://docs.flutter.dev/ui/navigation/deep-linking
For Android we can test deeplinks using adb commands:

adb shell 'am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "http://<web-domain>/details"' \
<package name>

Note! On Windows terminal there are some problems with passing more than one argument to deeplinks because of encoding. to avoid this issue you can use f.e. Git Bash terminal.

Or using any supportive apps from Google Play like :
https://play.google.com/store/apps/details?id=com.manoj.dlt&hl=en&gl=US

For iOS we just used build-in notepad with pasted deeplinks.

2. Nested bottom navigation
We had to rewrite our bottom navigation from scratch basing on provided example from github:
https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/shell_route.dart
It uses ShellRoute to create a new Navigator which is used to display any matching sub-routes instead of placing them on the root Navigator.
However few days ago go_router team has published new version of go_router v.7.1.0 which supports StatefulShellRoute

This is a gamechanger for preserving state in app’s navigation. Many people were waiting for this for a long time: https://github.com/flutter/flutter/issues/99124

It is easy to use:

final GoRouter _router = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/a',
routes: <RouteBase>[
StatefulShellRoute(
builder: (BuildContext context, GoRouterState state,
StatefulNavigationShell navigationShell) {
// This nested StatefulShellRoute demonstrates the use of a
// custom container for the branch Navigators. In this implementation,
// no customization is done in the builder function (navigationShell
// itself is simply used as the Widget for the route). Instead, the
// navigatorContainerBuilder function below is provided to
// customize the container for the branch Navigators.
return navigationShell;
},
navigatorContainerBuilder: (BuildContext context,
StatefulNavigationShell navigationShell, List<Widget> children) {
// Returning a customized container for the branch
// Navigators (i.e. the `List<Widget> children` argument).
//
// See ScaffoldWithNavBar for more details on how the children
// are managed (using AnimatedBranchContainer).
return ScaffoldWithNavBar(
navigationShell: navigationShell, children: children);
},
branches: <StatefulShellBranch>[
// The route branch for the first tab of the bottom navigation bar.
StatefulShellBranch(
navigatorKey: _tabANavigatorKey,
routes: <RouteBase>[
GoRoute(
// The screen to display as the root in the first tab of the
// bottom navigation bar.
path: '/a',
builder: (BuildContext context, GoRouterState state) =>
const RootScreenA(),
routes: <RouteBase>[
// The details screen to display stacked on navigator of the
// first tab. This will cover screen A but not the application
// shell (bottom navigation bar).
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) =>
const DetailsScreen(label: 'A'),
),
],
),
],
),

// The route branch for the third tab of the bottom navigation bar.
StatefulShellBranch(
// StatefulShellBranch will automatically use the first descendant
// GoRoute as the initial location of the branch. If another route
// is desired, specify the location of it using the defaultLocation
// parameter.
// defaultLocation: '/c2',
routes: <RouteBase>[
StatefulShellRoute(
builder: (BuildContext context, GoRouterState state,
StatefulNavigationShell navigationShell) {
// Just like with the top level StatefulShellRoute, no
// customization is done in the builder function.
return navigationShell;
},
navigatorContainerBuilder: (BuildContext context,
StatefulNavigationShell navigationShell,
List<Widget> children) {
// Returning a customized container for the branch
// Navigators (i.e. the `List<Widget> children` argument).
//
// See TabbedRootScreen for more details on how the children
// are managed (in a TabBarView).
return TabbedRootScreen(
navigationShell: navigationShell, children: children);
},
// This bottom tab uses a nested shell, wrapping sub routes in a
// top TabBar.
branches: <StatefulShellBranch>[
StatefulShellBranch(routes: <GoRoute>[
GoRoute(
path: '/b1',
builder: (BuildContext context, GoRouterState state) =>
const TabScreen(
label: 'B1', detailsPath: '/b1/details'),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder:
(BuildContext context, GoRouterState state) =>
const DetailsScreen(
label: 'B1',
withScaffold: false,
),
),
],
),
]),
StatefulShellBranch(routes: <GoRoute>[
GoRoute(
path: '/b2',
builder: (BuildContext context, GoRouterState state) =>
const TabScreen(
label: 'B2', detailsPath: '/b2/details'),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder:
(BuildContext context, GoRouterState state) =>
const DetailsScreen(
label: 'B2',
withScaffold: false,
),
),
],
),
]),
],
),
],
),
],
),
],
);
  /// Navigate to the current location of the branch at the provided index when
/// tapping an item in the BottomNavigationBar.
void _onTap(BuildContext context, int index) {
// When navigating to a new branch, it's recommended to use the goBranch
// method, as doing so makes sure the last navigation state of the
// Navigator for the branch is restored.
navigationShell.goBranch(
index,
// A common pattern when using bottom navigation bars is to support
// navigating to the initial location when tapping the item that is
// already active. This example demonstrates how to support this behavior,
// using the initialLocation parameter of goBranch.
initialLocation: index == navigationShell.currentIndex,
);
}
}

and works well:

We haven’t migrated to it yet, but it will be future improvement.

3. “login” guard

Previously we did research on a beamer package which was the winner of Google’s routing competition. They have nice feature called guards:

BeamGuard(
// on which path patterns (from incoming routes) to perform the check
pathPatterns: ['/login'],
// perform the check on all patterns that **don't** have a match in pathPatterns
guardNonMatching: true,
// return false to redirect
check: (context, location) => context.isUserAuthenticated(),
// where to redirect on a false check
beamToNamed: (origin, target) => '/login',
)

In short, guards will protect access to pages of your app if for example user is not signed in or signed out. In our case we needed 2 different types of “guards” in our app. The equivalent of it is called redirect in go_rotuer:

To add it we used 2 parameters: refreshListenable which is a observed stream and calls redirect when anything will be emitted on that stream.

refreshListenable: GoRouterRefreshStream(
GetIt.I<AuthorizationService>().authStream,
),
redirect: (context, state) async {
final loggedIn = context.isLoggedIn;
final loginLocation = state.namedLocation(loginRouteName);

if (!loggedIn) {
return Uri(
path: loginLocation,
queryParameters: {'deeplink': state.location},
).toString();
}
return null;
},
);

Note! refreshListenable requires object called Listenable which is not easy to create from a Stream, but go_router team created a class:

class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.asBroadcastStream().listen(
(dynamic _) => notifyListeners(),
);
}

late final StreamSubscription<dynamic> _subscription;

@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}

which was removed in one of the earlier versions:
https://github.com/flutter/flutter/issues/108128
However I didn’t find anything that we can easily use from Flutter SDK so I just copied and used this class. It works just fine.

Assumptions

Before we started migration we agreed to make some assumptions:

  1. We agreed to have One Source Of Truth as go_router state which will manipulate the app state via go_router methods like context.goNamed()
  2. We will cover migration behind feature flag to not broke production code and old navigation.
  3. We will support only deeplinks with our custom schema.
  4. When main migration (main screens and features) will be done, every new PR which touches navigation must contains also navigation for go_router.
  5. We will not make workarounds for problems added in issues, just wait until solution will be implemented in next version of go_rotuer (for example returning value for .pop method).

Migration

To make migration possible we created new “MyApp” main widget with configured router covered by feature flag:

runApp(
goRouter.isEnabled ? RouterApp() : MyApp(),
);

From now we reused all screens and widgets by adding to them routes, like:

const myScreenRouteName = 'MyScreenRoute';
const myScreenPathName = '/myScreen';

final myScreenRoute = GoRoute(
parentNavigatorKey: shellNavigatorKey,
path: myScreenPathName,
name: myScreenRouteName,
builder: (context, state) {
(...)
return const MyScreen();
},
);

We also were documenting all flows using go_router using markdown flows:

We started with all places where old navigator methods were used like .pop, .push, .popUntil etc. by adding routes and new method calls.

Deeplink support

By default go_router supports deeplinks out of the box, however in our case we needed to make them more custom. We don’t have one-screen login process so we had to “save” deeplink and “use” in right moment. To do that we added top-based (provided) cubit called DeeplinkCubit :

import 'package:flutter_bloc/flutter_bloc.dart';

class DeeplinkCubit extends Cubit<String> {
DeeplinkCubit(super.initialState);

void addDeeplink(String deeplink) {
emit(deeplink);
}
}

and when we receive deeplink on app state and loginguard doesn’t allow to use it (redirects to Login Screen) we have to store it by:

context.read<DeeplinkCubit>().addDeeplink('deeplink');

and read as:

context.read<DeeplinkCubit>().state;

To make it easier we added helper methods as extensions like:

extension Deeplink on GoRouterState {
String? get deeplink => queryParameters['deeplink'];
}

extension GoRouterContext on BuildContext {
GoRouterState get goRouterState => GoRouterState.of(this);

GoRouter get router => GoRouter.of(this);

DeeplinkCubit get deeplinkCubit => BlocProvider.of<DeeplinkCubit>(this);

void deeplinkOrGoNamed(
String name, {
Map<String, String> params = const {},
Map<String, dynamic> queryParams = const {},
Object? extra,
}) {
final deeplink = deeplinkCubit.state;
if (deeplink.isNotEmpty) {
deeplinkCubit.addDeeplink(null);
go(deeplink, extra: extra);
} else {
goNamed(
name,
pathParameters: params,
queryParameters: queryParams,
extra: extra,
);
}
}

void goNamedWithDeeplink(
String name, {
Map<String, String> params = const {},
Map<String, dynamic>? queryParams,
Object? extra,
}) {
final deeplink = goRouterState.deeplink;
if (deeplink != null) {
deeplinkCubit.addDeeplink(deeplink);
}
queryParams ??= {};
if (isNotBlank(deeplink)) {
queryParams.addAll({'deepLink': deeplink});
}
goNamed(
name,
pathParameters: params,
queryParameters: queryParams,
extra: extra,
);
}
}

From now we can use goNamedWithDeeplink to store deeplink until login process is done and deeplinkOrGoNamed when user is redirected from Login Screen to any loggedInScope content.

Troubleshooting

During the migration, we ran into issues related to previous solutions and new approach. We found solutions or workarounds that we would like to share.

  1. Scoping in app
    We divided app sections into 2 scopes for signed in users and signed out by adding routes in right order.
  2. Typesafety
    Go_rotuer supports typedsafety routes:
import 'package:go_router/go_router.dart';

part 'go_router_builder.g.dart';

@TypedGoRoute<HomeScreenRoute>(
path: '/',
routes: [
TypedGoRoute<SongRoute>(
path: 'song/:id',
)
]
)
@immutable
class HomeScreenRoute extends GoRouteData {
@override
Widget build(BuildContext context) {
return const HomeScreen();
}
}

@immutable
class SongRoute extends GoRouteData {
final int id;

const SongRoute({
required this.id,
});

@override
Widget build(BuildContext context) {
return SongScreen(songId: id.toString());
}
}

and usage

TextButton(
onPressed: () {
const SongRoute(id: 2).go(context);
},
child: const Text('Go to song 2'),
),

Although it has some limitations, discussed on mentioned earlier reddit topic:

However we needed to pass more than just Strings/ints to routes. So we agreed to pass to extras “data classes” objects when needed to pass more than one object via extras:

context.pushNamed(
bookstoreScreenRouteName,
extra: BookContent(
bookId: cubit.bookId,
shortDesc: shortDesc,
),
);

3. Proper handling of returning value from .pop(true/false)

We had few places where we need to return value after using .pop(value) method. Until go_router v 6.5.0 it was not possible to do, even workarounds were very difficult to apply. So we decided to wait when go_router team will add support for it and they did:

4. There is a problem with using BlocProvider in subroutes and get it.
It returns an error:

Bad state: No parent widget of type ... in the widget tree',

It is because it creates a new tree without provided bloc in context. Current solution is to pass BLoC to the routes in extras.
More info:

https://stackoverflow.com/questions/73956881/how-to-pass-a-bloc-to-another-screen-using-go-router-on-flutter

5. Migartion

Navigator.of(context).popUntil((route) => route.isFirst);

We can just use goNamed because it will build path from scratch.

6. Equivalent for

Navigator.of(this).push(
CupertinoPageRoute(
builder: (_) => destination),
);
}

Instead we can use just push/go with page builder like:

pageBuilder: (context, state) {
return CupertinoPage(
child: const MyOtherScreen(),
);
},

7. Notifications/push deeplink which will wait for fetching data

Problem here might be described like User receives notification with deeplink to some details page, but to do that app has to fetch some data and open details screen after that. To make it happen I used previously shown DeeplinkCubit to store deeplink value from notification. Then app goes to proper screen by goNamed method and when state changes to state fetched in BlocListener we added method

  bool _shouldHandleDeeplink(String deeplink) => (deeplink.isNotEmpty);

void handleDeeplink(
BuildContext context,
List<Data>? data
) {
final myObject = list?.firstWhereOrNull(
(item) => item.itemId == data.itemId,
);
if (myObject != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.deeplinkBloc.addDeeplink('');
context.pushNamed(
myDetailsScreen,
extra: DataDetails(item: myObject),
);
});
}
}
}

When data is fetched we check if we stored deeplink by _shouldHandleDeeplink method and then navigate to details screen.

Note! We added `WidgetsBinding.instance.addPostFrameCallback` to wait until Flutter will build a widget and then use context to change a state. Otherwise you will receive exception.

8. OpenContainer

OpenContainer does not work well with go_router. It is possible now to pass route settings to it:

/// Provides additional data to the [openBuilder] route pushed by the Navigator.
final RouteSettings? routeSettings;

But in our case there was some problems with it. So we decided to get rid off it and use custom animation instead:

Tween<RelativeRect> createTween(BuildContext context) {
final windowSize = MediaQuery.of(context).size;
final box = context.findRenderObject() as RenderBox;
final rect = box.localToGlobal(Offset.zero) & box.size;
final relativeRect = RelativeRect.fromSize(rect, windowSize);

return RelativeRectTween(
begin: relativeRect,
end: RelativeRect.fill,
);
}

and use it as transitionBuilder parametr to make custom transition:

final myScreenRoute = GoRoute(
path: 'myScreen',
name: myScreenRouteName,
pageBuilder: (context, state) {
final data = state.extra as MyScreenData;
return CustomTransitionPage<void>(
key: state.pageKey,
child: _MyScreen(
data: data.data,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final rectAnimation =
data.tween.chain(CurveTween(curve: Curves.ease)).animate(animation);
return Stack(
children: [
PositionedTransition(rect: rectAnimation, child: child),
],
);
},
);
},
);

Note! It is not 1:1 equivalent, but similar enough to be accepted. It might be further need for development to tweak it with some animation combinations.

9. go_router parent builders

If you have routes like

schema://com.example.name/ScreenA

and want to go deeper to

schema://com.example.name/ScreenA/detailsA page

you must remember that when you will call push/go for details

GoRouter.of(context).go('/a/details');

the builder from ScreenA (parent) also will be called:

[GoRouter] setting initial location /a
I/flutter (20958): A builder
[GoRouter] Using MaterialApp configuration
[GoRouter] going to /a/details
I/flutter (20958): A details builder
I/flutter (20958): A builder

Helpful links:

Summary — Are we/client happy ?

I presented a demo of our results to the client, mainly showcasing the deeplink support, and he/she/they were happy :) It was definitely more challenging to develop/migrate than we initially anticipated, but we managed to accomplish it. Occasionally, our QA team may still encounter bugs, whether directly related to our implementation of the new approach using go_router or not, but we now have a better understanding of how to handle them. We invested a significant amount of time in searching for solutions to our problems, and to save your time, this article has been written. I hope someone finds it useful.

In my personal opinion, go_router is continuously improving, similar to Dart when compared to Kotlin or Swift. I believe it is easier to start an app from scratch, but if that’s not possible, migration is still achievable.

If you have any questions or suggestions, please leave a comment below.

Thank you for reading.

--

--