Search code examples
flutternavigator

Navigate part of screen from drawer


let's say I have an app with the following setup:

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(),
        body: Container(
          color: Colors.grey[200],
          child: Row(
            children: [
              MainMenu(),
              Expanded(child: MainLoginScreen()),
            ],
          ),
        ));
  }
}

I would like to know how can I navigate only the MainLoginScreen widget from the MainMenu with any .push() method.

(I found a way to navigate from a context inside the mainloginscreen,by wrapping it with a MaterialApp widget, but what if I want to use the MainMenu widget instead, which has another context)


Solution

  • There is a general agreement that a 'screen' is a topmost widget in the route. An instance of 'screen' is what you pass to Navigator.of(context).push(MaterialPageRoute(builder: (context) => HereGoesTheScreen()). So if it is under Scaffold, it is not a screen. That said, here are the options:

    1. If you want to use navigation with 'back' button

    Use different screens. To avoid code duplication, create MenuAndContentScreen class:

    class MenuAndContentScreen extends StatelessWidget {
      final Widget child;
    
      MenuAndContentScreen({
        required this.child,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Container(
            color: Colors.grey[200],
            child: Row(
              children: [
                MainMenu(),
                Expanded(child: child),
              ],
            ),
          ),
        );
      }
    }
    

    Then for each screen create a pair of a screen and a nested widget:

    class MainLoginScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MenuAndContentScreen(
          child: MainLoginWidget(),
        );
      }
    }
    
    class MainLoginWidget extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        // Here goes the screen content.
      }
    }
    

    2. If you do not need navigation with 'back' button

    You may use IndexedStack widget. It can contain multiple widgets with only one visible at a time.

    class MenuAndContentScreen extends StatefulWidget {
      @override
      _MenuAndContentScreenState createState() => _MenuAndContentScreenState(
        initialContentIndex: 0,
      );
    }
    
    class _MenuAndContentScreenState extends State<MenuAndContentScreen> {
      int _index;
    
      _MainMenuAndContentScreenState({
        required int initialContentIndex,
      }) : _contentIndex = initialContentIndex;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Container(
            color: Colors.grey[200],
            child: Row(
              children: [
                MainMenu(
                  // A callback that will be triggered somewhere down the menu
                  // when an item is tapped.
                  setContentIndex: _setContentIndex,
                ),
                Expanded(
                  child: IndexedStack(
                    index: _contentIndex,
                    children: [
                      MainLoginWidget(),
                      SomeOtherContentWidget(),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      }
    
      void _setContentIndex(int index) {
        setState(() {
          _contentIndex = index;
        });
      }
    }
    

    The first way is generally preferred as it is declrative which is a major idea in Flutter. When you have the entire widget tree statically declared, less things can go wrong and need to be tracked. Once you feel it, it really is a pleasure. And if you want to avoid back navigation, use replacement as ahmetakil has suggested in a comment: Navigator.of(context).pushReplacement(...)

    The second way is mostly used when MainMenu needs to hold some state that needs to be preserved between views so we choose to have one screen with interchangeable content.

    3. Using a nested Navigator widget

    As you specifically asked about a nested Navigator widget, you may use it instead of IndexedStack:

    class MenuAndContentScreen extends StatefulWidget {
      @override
      _MenuAndContentScreenState createState() => _MenuAndContentScreenState();
    }
    
    class _MenuAndContentScreenState extends State<MenuAndContentScreen> {
      final _navigatorKey = GlobalKey();
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Container(
            color: Colors.grey[200],
            child: Row(
              children: [
                MainMenu(
                  navigatorKey: _navigatorKey,
                ),
                Expanded(
                  child: Navigator(
                    key: _navigatorKey,
                    onGenerateRoute: ...
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    // Then somewhere in MainMenu:
      final anotherContext = navigatorKey.currentContext;
      Navigator.of(anotherContext).push(...);
    

    This should do the trick, however it is a bad practice because:

    1. MainMenu knows that a particular Navigator exists and it should interact with it. It is better to either abstract this knowledge with a callback as in (2) or do not use a specific navigator as in (1). Flutter is really about passing information down the tree and not up.
    2. At some point you would like to highlight the active item in MainMenu, but it is hard for MainMenu to know which widget is currently in the Navigator. This would add yet another non-down interaction.

    For such interaction there is BLoC pattern

    In Flutter, BLoC stands for Business Logic Component. In its simpliest form it is a plain object that is created in the parent widget and then passed down to MainMenu and Navigator, these widgets may then send events through it and listen on it.

    class CurrentPageBloc {
      // int is an example. You may use String, enum or whatever
      // to identify pages.
      final _outCurrentPageController = BehaviorSubject<int>();
      Stream<int> _outCurrentPage => _outCurrentPageController.stream;
    
      void setCurrentPage(int page) {
        _outCurrentPageController.sink.add(page);
      }
    
      void dispose() {
        _outCurrentPageController.close();
      }
    }
    
    class MenuAndContentScreen extends StatefulWidget {
      @override
      _MenuAndContentScreenState createState() => _MenuAndContentScreenState();
    }
    
    class _MenuAndContentScreenState extends State<MenuAndContentScreen> {
      final _currentPageBloc = CurrentPageBloc();
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Container(
            color: Colors.grey[200],
            child: Row(
              children: [
                MainMenu(
                  currentPageBloc: _currentPageBloc,
                ),
                Expanded(
                  child: ContentWidget(
                    currentPageBloc: _currentPageBloc,
                    onGenerateRoute: ...
                  ),
                ),
              ],
            ),
          ),
        );
      }
    
      @override
      void dispose() {
        _currentPageBloc.dispose();
      }
    }
    
    // Then in MainMenu:
      currentPageBloc.setCurrentPage(1);
    
    // Then in ContentWidget's state:
      final _navigatorKey = GlobalKey();
      late final StreamSubscription _subscription;
    
      @override
      void initState() {
        super.initState();
        _subscription = widget.currentPageBloc.outCurrentPage.listen(_setCurrentPage);
      }
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: _navigatorKey,
          // Everything else.
        );
      }
    
      void _setCurrentPage(int currentPage) {
        // Can't use this.context, because the Navigator's context is down the tree.
        final anotherContext = navigatorKey?.currentContext;
        if (anotherContext != null) { // null if the event is emitted before the first build.
          Navigator.of(anotherContext).push(...); // Use currentPage
        }
      }
    
      @override
      void dispose() {
        _subscription.cancel();
      }
    

    This has advantages:

    • MainMenu does not know who will receive the event, if anybody.
    • Any number of listeners may listen on such events.

    However, there is still a fundamental flaw with Navigator. It can be navigated without MainMenu knowledge using 'back' button or by its internal widgets. So there is no single variable that knows which page is showing now. To highlight the active menu item, you would query the Navigator's stack which eliminates the benefits of BLoC.

    For all these reasons I still suggest one of the first two solutions.