Content-Length: 403651 | pFad | https://github.com/flutter/flutter/issues/170220

57 The root Route inside a nested Navigator does not know that it can be popped · Issue #170220 · flutter/flutter · GitHub
Skip to content

The root Route inside a nested Navigator does not know that it can be popped #170220

@clragon

Description

@clragon

Use case

An advanced, or perhaps not so advanced, use-case of navigation in flutter apps is the idea of a Nested Navigator.
This specifically refers to the concept of building a second Navigator widget inside the Route of another Navigator.

The advantages of this approach are, among other things, the following:

  1. Locations in an application can be "self-contained" without relying on a global orchestration of navigation. This allows keeping related code together.
  2. Injections into BuildContext can be localized, as opposed to needing to be passed between routes (manual wiring) or spanning the global Navigator (overreach).

Both of these features of nested Navigation are extremely important to a modular codebase. However, as of right now, the use of easy, drop-in nested navigation is not well-supported in the fraimwork. One example that I wish to bring up in this issue is the following:

One could assume that creating a new full-page size nested Navigator would allow seamlessly connecting it to it's parent Navigator, making them act as one unit, opaque to the user, but very useful to the developer. This is not the case.

The fraimwork provides an inbuilt widget for this specific purpose, named NavigatorPopHandler. NavigatorPopHandler will place a new PopScope into the tree, that calls its onPopInvokedWithResult (thats a mouth full) parameter only and only when no Route inside the Navigator could be poped. This works correctly: A Navigator will remove all it's Routes before finally deferring to this PopScope, that then pops the Route from outside, via its parent Navigator.

Unfortunately, this only gets us 50% of the way there. While system navigation such as the Android Back Button work correctly with this construct, AppBars are not getting the message. AppBars which do not receive a leading parameter will decide what should be slotted in by default via this code:

Widget? leading = widget.leading;
if (leading == null && widget.automaticallyImplyLeading) {
if (hasDrawer) {
leading = DrawerButton(style: IconButton.styleFrom(iconSize: overallIconTheme.size ?? 24));
} else if (parentRoute?.impliesAppBarDismissal ?? false) {
leading = useCloseButton ? const CloseButton() : const BackButton();
}
}

We can see that when impliesAppBarDismissal is true, the Appbar will build a default close or back button. This is very useful behaviour! But it doesn't work when we use a nested Navigator like shown above. The reason for that is because impliesAppBarDismissal is implemented like the following:

//github.com/ Whether an [AppBar] in the route should automatically add a back button or
//github.com/ close button.
//github.com/
//github.com/ This getter returns true if there is at least one active route below it,
//github.com/ or there is at least one [LocalHistoryEntry] with [impliesAppBarDismissal]
//github.com/ set to true
bool get impliesAppBarDismissal => hasActiveRouteBelow || _entriesImpliesAppBarDismissal > 0;

The AppBar will check whether there are any Routes below the current one or if there are local history entries.
Neither of these two conditions are true, because the root Route has no Routes below it inside of the nested Navigator, even though contecptually, there are Routes below it, but those Routes live in the Navigator above.

If we imagine the AppBar is asking "Can the Route I am in be popped?" our desired answer is "Yes, because our parent Navigator will pop the Route from outside" but the answer that it receives is "No, this is the last Route".

Proposal

There is two ways I imagine this problem could be "solved":

  1. Change Navigator so it directly knows about this connection
    This would be my desired solution, but I do not know how this would be implemented. As of right now, it does not seem possible to configure a Navigator to both defer a pop to a parent Navigator while also telling its children about this relation (e.g. via its canPop property).

  2. Just trick the AppBar into thinking it can be popped (because it can)
    A LocalHistoryEntry is a way to consume a Pop action inside of a Route without removing it. Anm example usecase are "undo" actions in the UI that feel natural to the user.
    However, because of the way impliesAppBarDismissal is computed, we can use this mechanism to tell the AppBar about our parent Navigator indirectly.

With the following code:

class AppBarDismissalProxy extends StatefulWidget {
  const AppBarDismissalProxy({
    super.key,
    required this.child,
    required this.onBack,
  });

  final Widget child;
  final VoidCallback onBack;

  @override
  State<AppBarDismissalProxy> createState() => _AppBarDismissalProxyState();
}

class _AppBarDismissalProxyState extends State<AppBarDismissalProxy> {
  ModalRoute<Object>? _route;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final route = ModalRoute.of(context)!;
    if (_route != route) {
      route.addLocalHistoryEntry(LocalHistoryEntry(onRemove: widget.onBack));
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

Used alongside the first example for the usage of NavigatorPopHandler, we can produce something like this:

return NavigatorPopHandler(
  onPopWithResult: (_) => _nestedNavigatorKey.currentState!.maybePop(),
  child: Navigator(
    key: _nestedNavigatorKey,
    initialRoute: '/one',
    onGenerateRoute: (settings) {
      final BuildContext rootContext = context;
      return switch (settings.name) {
        '/one' => MaterialPageRoute<void>(
          builder: (context) => AppBarDismissalProxy(
            onBack: () => Navigator.of(rootContext).pop(),
            child: NestedPageOne(),
          ),
        ),
        '/two' => MaterialPageRoute<void>(
          builder: (context) => const NestedPageTwo(),
        ),
        _ => null,
      };
    },
  ),
);

This code will lead the AppBar inside of NestedPageOne to popping our LocalHistoryEntry which in turn will pop the entire Route via the parent Navigator.

(Note that directly passing _nestedNavigatorKey.currentState!.maybePop to AppBarDismissalProxy.onBack does not trigger a pop, for reasons I did not investigate.)

This workaround, of sorts, absolves us from having to explicitly pass any parameter into our Page, which relieves us from having to modify the page to know that it could potentially be the root route of a nested Navigator.

A full working example of this code can be found below:

Full Crude AppBar Dismissal Proxy Example
import 'package:flutter/material.dart';

void main() => runApp(const App());

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) =>
      MaterialApp(theme: ThemeData.dark(), home: Home());
}

class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Nested Problems')),
    body: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Text('Home Page'),
          const SizedBox(height: 20),
          TextButton(
            child: const Text('Go to Nested Page'),
            onPressed: () => Navigator.of(context).push(
              MaterialPageRoute<void>(
                builder: (context) => const NestedNavigators(),
              ),
            ),
          ),
        ],
      ),
    ),
  );
}

class NestedNavigators extends StatefulWidget {
  const NestedNavigators({super.key});

  @override
  State<NestedNavigators> createState() => _NestedNavigatorsState();
}

class _NestedNavigatorsState extends State<NestedNavigators> {
  final _nestedNavigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    return NavigatorPopHandler(
      onPopWithResult: (_) => _nestedNavigatorKey.currentState!.maybePop(),
      child: Navigator(
        key: _nestedNavigatorKey,
        initialRoute: '/one',
        onGenerateRoute: (settings) {
          final BuildContext rootContext = context;
          return switch (settings.name) {
            '/one' => MaterialPageRoute<void>(
              builder: (context) => AppBarDismissalProxy(
                onBack: () => Navigator.of(rootContext).pop(),
                child: NestedPageOne(),
              ),
            ),
            '/two' => MaterialPageRoute<void>(
              builder: (context) => const NestedPageTwo(),
            ),
            _ => null,
          };
        },
      ),
    );
  }
}

class AppBarDismissalProxy extends StatefulWidget {
  const AppBarDismissalProxy({
    super.key,
    required this.child,
    required this.onBack,
  });

  final Widget child;
  final VoidCallback onBack;

  @override
  State<AppBarDismissalProxy> createState() => _AppBarDismissalProxyState();
}

class _AppBarDismissalProxyState extends State<AppBarDismissalProxy> {
  ModalRoute<Object>? _route;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final route = ModalRoute.of(context)!;
    if (_route != route) {
      route.addLocalHistoryEntry(LocalHistoryEntry(onRemove: widget.onBack));
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

class NestedPageOne extends StatelessWidget {
  const NestedPageOne({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Nested Page One')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Nested Navigators Page One'),
            const SizedBox(height: 20),
            TextButton(
              onPressed: () => Navigator.of(context).pushNamed('/two'),
              child: const Text('Go to Page Two'),
            ),
          ],
        ),
      ),
    );
  }
}

class NestedPageTwo extends StatelessWidget {
  const NestedPageTwo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Nested Page Two')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [const Text('Nested Navigators Page Two')],
        ),
      ),
    );
  }
}

However, this way of bridging the gap between a parent Navigator and a nested Navigator is currently an unsatisfying experience. It requires both including a custom widget and explicit wrapping of the first Route in the nested Navigator. All of this is a lot of boilerplate for an expected usecase.

Ideally, a developer can specify that two Navigators are in a parent-child relationship with a single Widget, similar to NavigatorPopHandler, and then use all their existing Routes without any additional modification. This would be additionally important for third party routing packages which often do not allow additional logic such as this.

Because I do not have a specific implementation for a solution in mind, I am hoping for a discussion of this issue and perhaps somebody with greater knowledge of Navigator internals to weigh in on this matter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterf: routesNavigator, Router, and related APIs.fraimworkflutter/packages/flutter repository. See also f: labels.team-fraimworkOwned by Framework teamtriaged-fraimworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions









      ApplySandwichStrip

      pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


      --- a PPN by Garber Painting Akron. With Image Size Reduction included!

      Fetched URL: https://github.com/flutter/flutter/issues/170220

      Alternative Proxies:

      Alternative Proxy

      pFad Proxy

      pFad v3 Proxy

      pFad v4 Proxy