Send your request Join Sii

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

go_router 7.1.1
Fig. 1 go_router 7.1.1

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

But then suddenly, they changed their mind and took go_router as an officially supported package. Many disappointed devs even created some Reddit threads to discuss this subject. So, should you migrate to go_router?

I will not answer 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 do some research. Our Architect asked go_router’s team directly about our app use cases, and we decided to wait until go_router 5.0 was available, which would solve all basic problems. So, we started with version 5.0.0, but before we added any code to the client app, we developed a PoC project to test all cases to see if they could handle.

Deeplink support

go_router supports deeplinks out of the box; we should only enable them on Android/iOS following the instructions. For Android, we can test deeplinks using adb commands:

adb shell 'am start -a android.intent.action.VIEW I will schedule some time for us to connect.
    -c android.intent.category.BROWSABLE Thank you for reaching out.
    -d "http://<web-domain>/details"' I will schedule some time for us to connect.
    <package name>

Note! There are some problems with passing more than one argument to deeplinks on the Windows terminal because of encoding. To avoid this issue, you can use f.e. Git Bash terminal. Or using any supportive apps from Google Play. For iOS we just used build-in notepad with pasted deeplinks.

Nested bottom navigation

We had to rewrite our bottom navigation from scratch based on the example provided by GitHub

It uses ShellRoute to create a new Navigator, which displays any matching sub-routes instead of placing them on the root Navigator.
However, a few days ago, the go_router team published a new version of go_router v.7.1.0, which supports StatefulShellRoute.

StatefulShellRoute
Fig. 2 StatefulShellRoute

This is a game-changer for preserving the state in the app’s navigation. Many people have been waiting for this for a long time.

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:

the result
Fig. 3 The result

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

“login” guard

Previously, we researched a beamer package that won Google’s routing competition. They have a nice feature called guards:

beamer | Flutter Package
beamer.dev Beamer uses the power of Router and implements all the underlying logic for you, letting you explore…pub.dev

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 your app’s pages if, for example, a 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:

Redirection topic
Redirection changes the location to a new one based on the application state. For example, redirection can be used to…pub.dev

To add it, we used 2 parameters:

  • refreshListenable, which is an observed stream and calls
  • redirect when anything is emitted from 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 an object called Listenable, which is not easy to create from a Stream, but the 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.
However, I found nothing 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 the go_router state, which will manipulate the app state via go_router methods like context.goNamed()
  2. We will cover migration behind feature flags so as not to break production code and old navigation.
  3. We will support only deeplinks with our custom schema.
  4. When the main migration (main screens and features) is done, every new PR that touches navigation must include navigation for go_router.
  5. We will not make workarounds for problems added to issues; we will just wait until the solution is implemented in the next version of go_rotuer (for example, returning value for .pop method).

Migration

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

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

From now we will reuse 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 documented all flows using go_router using markdown flows:

The flows
Fig. 4 The 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 a one-screen login process, so we had to “save” the deeplink and “use” immediately. To do that, we added a 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 a deeplink on the app state and loginguard doesn’t allow us 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 on, we can use goNamedWithDeeplink to store deeplink until the login process is done, and deeplinkOrGoNamed will be used when the user is redirected from LoginScreen to any loggedInScope content.

Troubleshooting

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

  1. Scoping in the app – we divided app sections into 2 scopes for signed-in users and signed-out by adding routes in the 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'),
),

Type-safe routes topic
Instead of using URL strings to navigate, go_router supports type-safe routes using the go_router_builder package.pub.dev

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

Reddit topic
Fig. 5 Reddit topic

However, we needed to pass more than 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,
    ),
);

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

We had a few places to return value after using the .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 for when go_router team to add support for it, and they did:

Supports returning values on pop
Fig. 6 Supports returning values on pop

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 providing a bloc in context. The current solution is to pass BLoC to the routes in extras (more information).

Migration

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

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

Equivalent for

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

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

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

Notifications/push deeplink, which will wait for fetching data

The problem here might be described as the User receiving a notification with a deeplink to some details page, but to do that, the app has to fetch some data and open the details screen after that. To make it happen, I used the previously shown DeeplinkCubit to store deeplink values from notifications. The app goes to the proper screen by the goNamed method, and when the state changes to the state fetched in BlocListener, we added the 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 builds a widget and then use context to change a state. Otherwise, you will receive an exception.

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 the Navigator pushes.
Final RouteSettings? routeSettings;

But in our case, there were some problems with it. So we decided to get rid of 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 parameter to make custom transitions:

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. There might be further need for development to tweak it with some animation combinations.

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 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

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 more challenging to develop/migrate than initially anticipated, but we managed to accomplish it. Occasionally, our QA team may still encounter bugs, whether directly related to implementing the new approach using go_router or not.

Still, 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 you time, this article has been written. I hope someone finds it useful.

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

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

Thank you for reading.

Helpful links

5/5 ( vote: 1)
Rating:
5/5 ( vote: 1)
Author
Avatar
Mateusz Dutkowski

Mateusz is a Senior Android and Flutter Developer with 11 years of Mobile experience. Proficient in Dart and Kotlin. Well-versed in diverse mobile technologies and adept at overcoming challenges and risks. He is skilled in multiple programming languages, a quick learner, and an innovative problem-solver. He is also passionate about astronomy and astrophotography. He creates fantastic cosmos pictures, which he shares on his website www.abovehead.pl. He is also a sailor with a license and a mobile app developer

Leave a comment

Your email address will not be published. Required fields are marked *

You might also like

More articles

Don't miss out

Subscribe to our blog and receive information about the latest posts.

Get an offer

If you have any questions or would like to learn more about our offer, feel free to contact us.

Send your request Send your request

Natalia Competency Center Director

Get an offer

Join Sii

Find the job that's right for you. Check out open positions and apply.

Apply Apply

Paweł Process Owner

Join Sii

SUBMIT

Ta treść jest dostępna tylko w jednej wersji językowej.
Nastąpi przekierowanie do strony głównej.

Czy chcesz opuścić tę stronę?