Rutger Bresjer
Rutger Bresjer

Reputation: 441

Navigator 2.0 - WillPopScope vs BackButtonListener

I have an app with a BottomNavigationBar and an IndexedStack which shows the tab content. Each tab has its own Router with its own RouterDelegate to mimic iOS-style tab behavior (where each tab has its own navigation controller).

Before, this app was only published on iOS. I'm now working on the Android version and need to correctly support the Android hardware back button. I did this by implementing a ChildBackButtonDispatchers per tab, which are a child of the parent RootBackButtonDispatcher. This works.

The issue I'm having now is that I use WillPopScope widgets to save a user's input when they leave a screen. This works correctly if the user taps the back button in the AppBar, but the callback isn't triggered when the user taps the hardware back button. I implemented BackButtonListeners on these screens as well, but this means I have to wrap the screens in both WillPopScopes and BackButtonListeners, both calling the same callback.

It this how it's supposed to be, or am I doing something wrong?

Relevant widget hierarchy:

My (simplified) router delegate looks like this:

class AppRouterDelegate extends RouterDelegate<AppRoute>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoute> {
  AppRouterDelegate({
    List<MaterialPage> initialPages = const [],
  }) : _pages = initialPages;

  final navigatorKey = GlobalKey<NavigatorState>();
  final List<MaterialPage> _pages;
  List<MaterialPage> get pages => List.unmodifiable(_pages);

  void push(AppRoute route) {
    final shouldAddPage = _pages.isEmpty || (_pages.last.arguments as AppRoute != route);

    if (!shouldAddPage) {
      return;
    }

    _pages.add(route.page);
    notifyListeners();
  }

  @override
  Future<void> setNewRoutePath(AppRoute route) async {
    _pages.clear();
    _pages.add(route.page);
    notifyListeners();

    return SynchronousFuture(null);
  }

  @override
  Future<bool> popRoute() {
    if (canPop) {
      pop();
      return SynchronousFuture(true);
    }
    return SynchronousFuture(false);
  }

  bool get canPop => _pages.length > 1;

  void pop() {
    if (canPop) {
      _pages.remove(_pages.last);
      notifyListeners();
    }
  }

  void popTillRoot() {
    while (canPop) {
      _pages.remove(_pages.last);
    }
    notifyListeners();
  }

  bool _onPopPage(Route<dynamic> route, result) {
    final didPop = route.didPop(result);
    if (!didPop) {
      return false;
    }

    if (canPop) {
      pop();
      return true;
    } else {
      return false;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      onPopPage: _onPopPage,
      pages: pages,
    );
  }
}

I found this Flutter issue which makes me think I shouldn't have the WillPopScope at all, but without it the taps in the AppBar are not caught...

Upvotes: 4

Views: 3208

Answers (1)

jjoelson
jjoelson

Reputation: 5941

I know this question is old, but here's an answer for others who arrive here.

From the AppBar leading documentation (emphasis mine):

If this is null and automaticallyImplyLeading is set to true, the AppBar will imply an appropriate widget. For example, if the AppBar is in a Scaffold that also has a Drawer, the Scaffold will fill this widget with an IconButton that opens the drawer (using Icons.menu). If there's no Drawer and the parent Navigator can go back, the AppBar will use a BackButton that calls Navigator.maybePop.

So in order to make the Android back button work the same way as the App Bar's back button, you need to use the Navigator.maybePop method, which will respect WillPopScope.

Conveniently, Flutter provides PopNavigatorRouterDelegateMixin to make this easy; it provides an implementation of popRoute that uses maybePop and therefore will work identically to the App Bar's automatically-generated back/dismiss button. The nice thing about Flutter being open source is that you can jump into the Flutter code to verify what the mixin is doing:

mixin PopNavigatorRouterDelegateMixin<T> on RouterDelegate<T> {
  /// The key used for retrieving the current navigator.
  ///
  /// When using this mixin, be sure to use this key to create the navigator.
  GlobalKey<NavigatorState>? get navigatorKey;

  @override
  Future<bool> popRoute() {
    final NavigatorState? navigator = navigatorKey?.currentState;
    if (navigator == null)
      return SynchronousFuture<bool>(false);
    return navigator.maybePop();
  }
}

So I think the only mistake in your code is that, even though you've mixed-in PopNavigatorRouterDelegateMixin on your router delegate, you are also providing your own override of popRoute. When the user taps the Android back button, your popRoute implementation is called, and it just pops the last page. If you delete your popRoute override and let the mixin do its thing, then the Android back button will function identically to the App Bar back/dismiss button.

Upvotes: 3

Related Questions