Skip to main content

Command Palette

Search for a command to run...

Navigator onPopPage versus onDidRemovePage, Part 2

Updated
7 min read

Recap part 1.

In the previous article about this subject, I explained the limitations that I have ran into, with the default onDidRemovePage implementation. In this article I will show how I’ve overcome or better said, circumvented these limitations.

First of all, these are limitations that I’ve found:

  1. onDidRemovePage is called after the pop transition has started.

  2. The BackButton in the AppBar calls onDidRemovePage.

  3. The Android back button calls onDidRemovePage.

  4. The back gesture on iOS calls onDidRemovePage.

  5. Predictive back gesture on Android (hopefully) calls onDidRemovePage
    I’ve haven’t tested this yet. At this time of writing this is still experimental, but I expect that it works the same.

As onDidRemovePage is called after the fact, it is not possible at that point to prevent the pop without introducing ugly animations or other pitfalls (like the inability to use a showDialog at that time, while the transition happens).

The solution: prevent onDidRemovePage from being called.

BackButton

In this section, I show how a standard BackButton can be adjusted to fit our needs.

A standard BackButton executes this code when pressed:

class BackButton extends _ActionButton {
  ...
  @override
  void _onPressedCallback(BuildContext context) => Navigator.maybePop(context);
}

This causes the BackButton to call onDidRemovePage. We can override this behaviour by passing our own callback in the onPressed property.

BackButton(onPressed: rubigoRouter.ui.pop)

We can pass this to the leading property of an AppBar. But this will cause it to be shown always. That is not what we want because the standard behaviour of the AppBar is to hide the BackButton on the first page of the app.

This can be accomplished the same with this helper method. It also causes the AppBar to rebuild when the ModalRoute.canPopOf property changes.

/// Use this function for [AppBar.leading] to show a standard BackButton that
/// delegates onPressed to the [RubigoRouter.ui].pop function.
Widget? rubigoBackButton(
  BuildContext context,
  RubigoRouter rubigoRouter,
) {
  return ModalRoute.canPopOf(context) ?? false
      ? BackButton(onPressed: rubigoRouter.ui.pop)
      : null;
}

This is how the “fixed” AppBar looks like:

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      leading: rubigoBackButton(context, controller.rubigoRouter),
    ...
    ),
  );
}

Be aware that this only fixes the AppBar when using a BackButton. The AppBar can also use a DrawerIconButton or a CloseButton and they may need similar fixes.

Android back button

The Android back button can be a dedicated (hardware) button on the device, or a (software) button provided by the OS. You can catch Android back button presses by providing a BackButtonDispatcher to the MaterialApp. Here below I show it with a RootBackButtonDispatcher.

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      backButtonDispatcher: RootBackButtonDispatcher,
      ...
      builder: (context, child) {
        return ...
        );
      },
    );
  }

The standard RootBackButtonDispatcher returns false when didPopRoute() is called, to indicate that the pop was not handled.

We have to provide our own implementation of a RootBackButtonDispatcher, to handle back button presses.

class RubigoRootBackButtonDispatcher extends RootBackButtonDispatcher {
  ...

  @override
  Future<bool> didPopRoute() async {
    if (currentRouteIsPage(rubigoRouter)) {
      //Current route is page based route (MaterialPage or CupertinoPage)
      await rubigoRouter.ui.pop();
      return true;
    }
    // This is for the default back button behaviour. For example to close a dialog when
    // the user presses the Android hardware back button.
    // Current route is a pageless route. super.didPopRoute() called.',
    await super.didPopRoute();
    return true;
  }
}

This is the implementation of currentRouteIsPage().
Please see this stack overflow post, answered by Rémi Rousselet:
https://stackoverflow.com/questions/50817086/how-to-check-which-the-current-route-is

/// Check if the current topmost route is a pageless route or a
/// page(full)route.
bool currentRouteIsPage(RubigoRouter rubigoRouter) {
  var isPage = false;
  final context = rubigoRouter.navigatorKey.currentContext;
  if (context == null) {
    return false;
  }
  // https://stackoverflow.com/questions/50817086/how-to-check-which-the-current-route-is
  Navigator.of(context).popUntil(
    (route) {
      if (route.settings is Page) {
        isPage = true;
      }
      return true;
    },
  );
  return isPage;
}

Back gestures

It is not possible to know upfront that a back gesture is going to happen because it is a user interaction. The user slides the page to the right, we can not know that upfront.

While it is possible to disable back gestures with a PopScope widget, this is probably not what you want. Popping pages by sliding the page to the right feels natural when interacting with apps on mobile devices.

So here we have to use the onDidRemovePage callback, but there are some caveats:

  1. onDidRemovePage is called for every Page that is removed from the stack.

  2. onDidRemovePage is called when the transition has not finished yet.

onDidRemovePage is called for every Page

Although this might sounds obvious, this is not how, the now deprecated, onPopPage worked. The onPopPage callback was called when the user wanted to navigate back. It implicitly meant the user wanted to pop the topmost page and it was possible to block the pop by returning false.

The new onDidRemovePage is here to support back gestures, so it doesn’t provide a way to block the pop. In addition to that, it is called for every page that is removed from the stack, so also for pages that are not on the top.

For example, assume we replace this stack of screens:

  1. Page1

  2. Page2

  3. Page3

With this stack of screens:

  1. Page4

  2. Page5

  3. Page6

For this example onDidRemovePage is called three times:

  1. onDidRemovePage(Page3)

  2. onDidRemovePage(Page2)

  3. onDidRemovePage(Page1)

The most easy solution is to ignore calls to onDidRemovePage when the removed page is not the one that is current on top.

onDidRemovePage is called when the transition has not finished yet.

Another challenge is that while we are informed that the topmost page has popped, the transition is still ongoing. Assume we wanted to ask the user if he is sure about his action, and we want to ask that with a standard call to showDialog. Chances are that you will never see that dialog.

Dialogs are pushed on the stack as pageless routes. Pageless routes are connected to the Page they are on. If you show the dialog too early, it will be pushed on the Page that is about to be popped.

Luckily there are some hooks that we can use to wait for the transition to finish.

The NavigatorState has a property userGestureInProgressNotifier, which tells us if the onDidRemovePage was caused by a back gesture and when the gesture is finished.

Complete implementation

For completeness this is the implementation I use:

  void onDidRemovePage(Page<Object?> page) {
    final pageKey = page.key;
    if (pageKey == null) {
      final txt =
          'PANIC: page.key must be of type ValueKey<$SCREEN_ID>, but found '
          'null.';
      unawaited(_logNavigation(txt));
      throw UnsupportedError(txt);
    }
    if (pageKey is! ValueKey<SCREEN_ID>) {
      final txt =
          'PANIC: page.key must be of type ValueKey<$SCREEN_ID>, but found '
          '${pageKey.runtimeType}.';
      unawaited(_logNavigation(txt));
      throw UnsupportedError(txt);
    }
    final removedScreenId = pageKey.value;
    final lastScreenId = _rubigoStackManager.screens.last.screenId;
    if (removedScreenId != lastScreenId) {
      // With this new event, we also receive this event when pages are removed
      // programmatically from the stack. Here onDidRemovePage was (probably)
      // initiated by the business logic, as the last page on the stack is not
      // the one that got removed. In this case the screenStack is already
      // valid.
      unawaited(
        _logNavigation(
          'onDidRemovePage(${removedScreenId.name}) called. Last page is '
          '${lastScreenId.name}, ignoring.',
        ),
      );
      return;
    }

    // handle the back event.
    unawaited(
      _logNavigation(
        'onDidRemovePage(${removedScreenId.name}) called.',
      ),
    );

    Future<void> callPop() async {
      // This function calls ui.pop and keeps track if updateScreens is being
      // called, while executing ui.pop().
      var updateScreensIsCalled = false;
      void updateScreenCallback() => updateScreensIsCalled = true;
      _rubigoStackManager.updateScreensCallBack.add(updateScreenCallback);
      await ui.pop();
      if (!updateScreensIsCalled) {
        await _rubigoStackManager.updateScreens();
      }
      _rubigoStackManager.updateScreensCallBack.remove(updateScreenCallback);
    }

    final navState = _navigatorKey.currentState;
    if (navState == null) {
      // We cannot continue if we cannot access Flutter's navigator.
      return;
    }

    // Remove the last page, so the screens is the same as Flutter expects.
    // Just in case the widget tree rebuilds for some reason.
    // Note: this will not inform the listeners, this is intended behavior.
    screens.removeLast();

    Future<void> gestureCallback() async {
      final inProgress = navState.userGestureInProgress;
      if (!inProgress) {
        navState.userGestureInProgressNotifier.removeListener(gestureCallback);
        await callPop();
      }
    }

    if (navState.userGestureInProgress) {
      // We have to wait for the gesture to finish. Otherwise pageless routes,
      // that might have been added in mayPop (like showDialog), are popped
      // together with the page. That is how Navigator 2.0 works, nothing we
      // can about that. Wait for the gesture to complete and the perform the
      // pop().
      navState.userGestureInProgressNotifier.addListener(gestureCallback);
      return;
    }

    final warningText = '''
RubigoRouter warning.
"onDidRemovePage called" for ${removedScreenId.name}, but the source was not a userGesture. 
The cause is most likely that Navigator.maybePop(context) was (indirectly) called.
This can happen when:
- A regular BackButton was used to pop this page. Solution: Use a rubigoBackButton in the AppBar.
- The MaterialApp.backButtonDispatcher was not a RubigoRootBackButtonDispatcher.
- The pop was not caught by a RubigoBackGesture widget.
''';
    unawaited(_logNavigation(warningText));

    // This is a workaround for the following exception:
    // Unhandled Exception: 'package:flutter/src/widgets/navigator.dart': Failed assertion: line 4931 pos 12: '!_debugLocked': is not true.
    // Which can happen if a showDialog (or other pageless route) was pushed in
    // the mayPop callback. This is a workaround and should not end up in
    // production. Read the warning here above.
    Future.delayed(Duration.zero, callPop);
  }