How to detect only imperative pops after onPopPage is deprecated?

According to this breaking change, the onDidRemovePage property has replaced onPopPage.

Here’s what they do:

  • onPopPage: Previously, this callback was used to call [Route.didPop] and return whether the pop was successful. It was invoked only for imperative pop actions.
  • onDidRemovePage: This callback is triggered when the [Route] associated with a [Page] is removed from the Navigator. It handles both imperative (e.g., Navigator.pop) and declarative removals (e.g., state changes).

The breaking change has caused issues for my implementation because I need to differentiate imperative pops from declarative ones to update the internal page state that I manage.

How can I achieve this distinction with onPopPage now deprecated?

4 Likes

Could you describe how you make your state depending on this destination?
Could you return a value in case of imperative pop to signal it?

This is my RouterDelegate class:

class PickerAppRouterDelegate extends RouterDelegate<void> with ChangeNotifier {
  final ValueListenable<List<AppRoutePage>> pages;
  final Future<bool> Function() onPagePoppedWithOperatingSystemIntent;
  final void Function(String? poppingPageName) onPagePoppedImperatively;
  GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  PickerAppRouterDelegate({
    required this.pages,
    required this.onPagePoppedWithOperatingSystemIntent,
    required this.onPagePoppedImperatively,
  }) {
    pages.addListener(notifyListeners);
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: pages.value,
      // Using the deprecated onPopPage callback as a temporary solution because:
      // - The new onDidRemovePage is triggered for both imperative and declarative pops.
      // - onPopPage, however, is invoked only for imperative actions, which aligns better with our requirements.
      // If we don't track pages popped imperatively, it could cause inconsistencies in the app's internal page stack.
      //
      // See discussion: https://forum.example.com/t/how-to-detect-only-imperative-pops
      // ignore: deprecated_member_use
      onPopPage: (route, result) {
        onPagePoppedImperatively(route.settings.name);

        return route.didPop(result);
      },
    );
  }

  @override
  Future<void> setNewRoutePath(void configuration) async {
    /* No implementation needed */
  }

  /// Handles pops caused by system interactions (e.g., swipe gestures or back button).
  /// If this method returns false, the entire app will exit.
  @override
  Future<bool> popRoute() {
    final currentContext = navigatorKey.currentContext;
    if (currentContext != null) {
      if (_closeBottomAndActionSheets(currentContext)) {
        return Future.value(true);
      }

      return onPagePoppedWithOperatingSystemIntent();
    }

    return Future.value(false);
  }
}

The data layer class keeps track of the pages:

void onPagePoppedImperatively(String? poppingPageName) {
  if (poppingPageName == null) {
    return;
  }

  final appRoute = AppRouteName.findFromPageName(poppingPageName);
  switch (appRoute) {
    case AppRouteName.home:
    case AppRouteName.auth:
    case AppRouteName.tasks:
      break;
    case AppRouteName.detailSetting:
      _pages.value = [const TasksPage(), const SettingsPage()];
      break;
    case AppRouteName.guides:
    case AppRouteName.detailContent:
      _pages.value = [const TasksPage(), const GuidesPage()];
      break;
    case AppRouteName.storeDetail:
    case AppRouteName.hoursDetail:
      _pages.value = [const TasksPage(), const StorePage()];
      break;
    default:
      _pages.value = [const TasksPage()];
      break;
  }
}

/// Handles pops caused by system interactions (e.g., swipe gestures or back button).
/// Returning false will exit the app.
@Override
Future<bool> onPagePoppedWithOperatingSystemIntent() {
  final lastPage = _pages.value.lastOrNull;
  if (lastPage == null) {
    return Future.value(false);
  }

  switch (lastPage) {
    case HomePage():
    case TasksPage():
    case AuthPage():
      return Future.value(false);
    case DetailSettingPage():
      _pages.value = [const TasksPage(), const SettingsPage()];
      return Future.value(true);
    case GuidesPage():
    case DetailContentPage():
      _pages.value = [const TasksPage(), const GuidesPage()];
      return Future.value(true);
    case StoreDetailPage():
    case HoursDetailPage():
      _pages.value = [const TasksPage(), const StorePage()];
      return Future.value(true);
    default:
      _pages.value = [const TasksPage()];
      return Future.value(true);
  }
}

I think I still don’t understand why you track your pages that way in your data layer. Can you elaborate on the idea behind it?

The business logic in domain layer decides the navigation stack, then the representation of the navigation stack in the domain layer is mapped to routes by the Navigator in the widget layer. This is why I need to sync the current state of the navigation stack with the actual in the widget layer.

What about using a NavigationObserver?

Good point, but unfortunately, didPop of NavigationObserver is called for both imperative and declarative pops.

Could you pass a certain marker Objekt when pushing Imperatively that you then also return so you can check which object gets returned when popping?

(Still wondering why your data layer needs to keep track, we don’t do navigation control from the data layer but purely from the UI)

I think it is related to the predictive back gesture on Android and the swipe back on iOS.

I seem to have the exact same issue. Have you found a good workaround? I think about using a GlobalKey on each page to identify the one that was popped from the stack, but I’m not sure.

1 Like

This is how I manage it now, but that does not answer your question how you can tell the difference how the page got removed.

 @override
  void onDidRemovePage(Page<Object?> page) {
    late final Widget screen;
    if (page is MaterialPage) {
      screen = page.child;
    } else if (page is CupertinoPage) {
      screen = page.child;
    } else {
      throw ArgumentError('Page must be of type MaterialPage or CupertinoPage');
    }
    if (availableScreens.findScreen(screenStack.last) == screen) {
      screenStack.removeLast();
    }
  }
1 Like

I think I will postpone finding solution until it is fully removed from the SDK

1 Like

I’ve reported an issue here:
https://github.com/flutter/flutter/issues/160463

The new way as it is now will not work for me.

1 Like

Thanks for doing this. I will give thumbs up when I have chance.

1 Like