# Navigator onPopPage versus onDidRemovePage, Part 2

## Recap part 1.

In the [previous](https://yapb.dev/navigator-onpoppage-versus-ondidremovepage) 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:

```dart
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.

```dart
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.

```dart
/// 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:

```dart
@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`.

```dart
  @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.

```dart
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](https://stackoverflow.com/questions/50817086/how-to-check-which-the-current-route-is)

```dart
/// 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:

```dart
  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);
  }
```
