4

I want to have a bottom navigation bar in my app which behaves like followed:

  1. each tab should have it's own nested navigator, so that i can switch to a subroute while persisting the bottomnavbar
  2. when switching to another tab, I want to be able to get back to previous tab with the back button
  3. when I tap on tab 1 again, I don't want to instantiate the subroute again but I want to get back to it's last state .. for example if I was on route '/tab1/subRoute1' I want to land on this view again

I was able to achieve 1 & 2, but I am stuck on point 3. Here is my construct:

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bottom NavBar Demo',
      home: BottomNavigationBarController(),
    );
  }
}

class BottomNavigationBarController extends StatefulWidget {
  BottomNavigationBarController({Key key}) : super(key: key);

  @override
  _BottomNavigationBarControllerState createState() =>
      _BottomNavigationBarControllerState();
}

class _BottomNavigationBarControllerState
    extends State<BottomNavigationBarController> {
  int _selectedIndex = 0;
  List<int> _history = [0];
  GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();

  final List<BottomNavigationBarRootItem> bottomNavigationBarRootItems = [
    BottomNavigationBarRootItem(
      routeName: '/',
      nestedNavigator: HomeNavigator(
        navigatorKey: GlobalKey<NavigatorState>(),
      ),
      bottomNavigationBarItem: BottomNavigationBarItem(
        icon: Icon(Icons.home),
        title: Text('Home'),
      ),
    ),
    BottomNavigationBarRootItem(
      routeName: '/settings',
      nestedNavigator: SettingsNavigator(
        navigatorKey: GlobalKey<NavigatorState>(),
      ),
      bottomNavigationBarItem: BottomNavigationBarItem(
        icon: Icon(Icons.home),
        title: Text('Settings'),
      ),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: WillPopScope(
        onWillPop: () async {
          final nestedNavigatorState =
              bottomNavigationBarRootItems[_selectedIndex]
                  .nestedNavigator
                  .navigatorKey
                  .currentState;

          if (nestedNavigatorState.canPop()) {
            nestedNavigatorState.pop();
            return false;
          } else if (_navigatorKey.currentState.canPop()) {
            _navigatorKey.currentState.pop();
            return false;
          }
          return true;
        },
        child: Navigator(
          key: _navigatorKey,
          initialRoute: bottomNavigationBarRootItems.first.routeName,
          onGenerateRoute: (RouteSettings settings) {
            WidgetBuilder builder;

            builder = (BuildContext context) {
              return bottomNavigationBarRootItems
                  .where((element) => element.routeName == settings.name)
                  .first
                  .nestedNavigator;
            };

            return MaterialPageRoute(
              builder: builder,
              settings: settings,
            );
          },
        ),
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: bottomNavigationBarRootItems
            .map((e) => e.bottomNavigationBarItem)
            .toList(),
        currentIndex: _selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: _onItemTapped,
      ),
    );
  }

  void _onItemTapped(int index) {
    if (index == _selectedIndex) return;
    setState(() {
      _selectedIndex = index;
      _history.add(index);
      _navigatorKey.currentState
          .pushNamed(bottomNavigationBarRootItems[_selectedIndex].routeName)
          .then((_) {
        _history.removeLast();
        setState(() => _selectedIndex = _history.last);
      });
    });
  }
}

class BottomNavigationBarRootItem {
  final String routeName;
  final NestedNavigator nestedNavigator;
  final BottomNavigationBarItem bottomNavigationBarItem;

  BottomNavigationBarRootItem({
    @required this.routeName,
    @required this.nestedNavigator,
    @required this.bottomNavigationBarItem,
  });
}

abstract class NestedNavigator extends StatelessWidget {
  final GlobalKey<NavigatorState> navigatorKey;

  NestedNavigator({Key key, @required this.navigatorKey}) : super(key: key);
}

class HomeNavigator extends NestedNavigator {
  HomeNavigator({Key key, @required GlobalKey<NavigatorState> navigatorKey})
      : super(
          key: key,
          navigatorKey: navigatorKey,
        );

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      initialRoute: '/',
      onGenerateRoute: (RouteSettings settings) {
        WidgetBuilder builder;
        switch (settings.name) {
          case '/':
            builder = (BuildContext context) => HomePage();
            break;
          case '/home/1':
            builder = (BuildContext context) => HomeSubPage();
            break;
          default:
            throw Exception('Invalid route: ${settings.name}');
        }
        return MaterialPageRoute(
          builder: builder,
          settings: settings,
        );
      },
    );
  }
}

class SettingsNavigator extends NestedNavigator {
  SettingsNavigator({Key key, @required GlobalKey<NavigatorState> navigatorKey})
      : super(
          key: key,
          navigatorKey: GlobalKey<NavigatorState>(),
        );

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      initialRoute: '/',
      onGenerateRoute: (RouteSettings settings) {
        WidgetBuilder builder;
        switch (settings.name) {
          case '/':
            builder = (BuildContext context) => SettingsPage();
            break;
          default:
            throw Exception('Invalid route: ${settings.name}');
        }
        return MaterialPageRoute(
          builder: builder,
          settings: settings,
        );
      },
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Page'),
      ),
      body: Center(
        child: RaisedButton(
          onPressed: () => Navigator.of(context).pushNamed('/home/1'),
          child: Text('Open Sub-Page'),
        ),
      ),
    );
  }
}

class HomeSubPage extends StatefulWidget {
  const HomeSubPage({Key key}) : super(key: key);

  @override
  _HomeSubPageState createState() => _HomeSubPageState();
}

class _HomeSubPageState extends State<HomeSubPage> {
  String _text;

  @override
  void initState() {
    _text = 'Click me';
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Sub Page'),
      ),
      body: Center(
        child: RaisedButton(
          onPressed: () => setState(() => _text = 'Clicked'),
          child: Text(_text),
        ),
      ),
    );
  }
}

class SettingsPage extends StatelessWidget {
  const SettingsPage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Settings Page'),
      ),
      body: Container(
        child: Center(
          child: Text('Settings Page'),
        ),
      ),
    );
  }
}

When you run this code, then tap "Open Sub-Page" -> tap "Click me" you should see "Clicked" in "Home Sub Page". if you now click on "Settings" in the bottom nav bar and then use the android back button you are back on "Home"-tab showing the exact same page where the button says "Clicked". if you click on "Settings" and then on "Home" in the bottom nav bar you are again on the exact same page where the button says "clicked". That is exactly the behaviour I need BUT when you do the latter you also get an error saying "Duplicate GlobalKey detected in widget tree.". And if you now tap the android back button twice you land on an empty page (out of obious reasons). How can I avoid this Duplicate Global Key Error without losing my desired behaviour?

I hope my explanation makes sense ..

An example app where this is implemented perfectly is Instagram.

This is related to: Flutter persistent navigation bar with named routes?

4
  • 1
    Would you explain why you want "each tab should have it's own nested navigator" ?
    – Kahou
    Commented Mar 4, 2020 at 1:26
  • Because I want to have a navigation hierarchy for each tab, but I also want to be able to switch back to the previous tab when I tap the back button Commented Mar 5, 2020 at 17:57
  • 1
    In this case, only one navigator is actually needed.
    – Kahou
    Commented Mar 5, 2020 at 23:04
  • Can one Navigator keep track of multiple stacks? I.e., if I have two tabs, Home and Settings, and in Home I tap a button that adds a route to the stack, then I switch to Settings and tap another button that adds a route. Can I tap home and see the same state I had before with a single Navigator?
    – sleighty
    Commented Jul 16, 2021 at 23:19

1 Answer 1

13
+100

you want make navigation tabs just like twitter , Instagram, apps so every tab has it own navigation history and scoop
i guess i understand what you want achieve , but you do it in a wrong way you should use 'tabBarView' for tabs content inside 'ScoopWillPop' and make every tab manage its own navigate history , after so many hard work on one of my projects i found the best way to implement this idea
i made many changes on your code , i hope to be clear

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Bottom NavBar Demo',
      home: BottomNavigationBarController(),
    );
  }
}

class BottomNavigationBarController extends StatefulWidget {
  BottomNavigationBarController({Key key}) : super(key: key);

  @override
  _BottomNavigationBarControllerState createState() =>
      _BottomNavigationBarControllerState();
}

class _BottomNavigationBarControllerState
    extends State<BottomNavigationBarController> with SingleTickerProviderStateMixin{
  int _selectedIndex = 0;
  List<int> _history = [0];
  GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
  TabController _tabController;
  List<Widget> mainTabs;
  List<BuildContext> navStack = [null, null]; // one buildContext for each tab to store history  of navigation

  @override
  void initState() {
    _tabController = TabController(vsync: this, length: 2);
    mainTabs = <Widget>[
      Navigator(
          onGenerateRoute: (RouteSettings settings){
            return PageRouteBuilder(pageBuilder: (context, animiX, animiY) { // use page PageRouteBuilder instead of 'PageRouteBuilder' to avoid material route animation
              navStack[0] = context;
              return HomePage();
            });
          }),
      Navigator(
          onGenerateRoute: (RouteSettings settings){
            return PageRouteBuilder(pageBuilder: (context, animiX, animiY) {  // use page PageRouteBuilder instead of 'PageRouteBuilder' to avoid material route animation
              navStack[1] = context;
              return SettingsPage();
            });
          }),
    ];
    super.initState();
  }

  final List<BottomNavigationBarRootItem> bottomNavigationBarRootItems = [
    BottomNavigationBarRootItem(
      bottomNavigationBarItem: BottomNavigationBarItem(
        icon: Icon(Icons.home),
        title: Text('Home'),
      ),
    ),
    BottomNavigationBarRootItem(
      bottomNavigationBarItem: BottomNavigationBarItem(
        icon: Icon(Icons.settings),
        title: Text('Settings'),
      ),
    ),
  ];


  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      child: Scaffold(
        body: TabBarView(
          controller: _tabController,
          physics: NeverScrollableScrollPhysics(),
          children: mainTabs,
        ),
        bottomNavigationBar: BottomNavigationBar(
          items: bottomNavigationBarRootItems.map((e) => e.bottomNavigationBarItem).toList(),
          currentIndex: _selectedIndex,
          selectedItemColor: Colors.amber[800],
          onTap: _onItemTapped,
        ),
      ),
      onWillPop: () async{
        if (Navigator.of(navStack[_tabController.index]).canPop()) {
          Navigator.of(navStack[_tabController.index]).pop();
          setState((){ _selectedIndex = _tabController.index; });
          return false;
        }else{
          if(_tabController.index == 0){
            setState((){ _selectedIndex = _tabController.index; });
            SystemChannels.platform.invokeMethod('SystemNavigator.pop'); // close the app
            return true;
          }else{
            _tabController.index = 0; // back to first tap if current tab history stack is empty
            setState((){ _selectedIndex = _tabController.index; });
            return false;
          }
        }
      },
    );
  }

  void _onItemTapped(int index) {
    _tabController.index = index;
    setState(() => _selectedIndex = index);
  }

}

class BottomNavigationBarRootItem {
  final String routeName;
  final NestedNavigator nestedNavigator;
  final BottomNavigationBarItem bottomNavigationBarItem;

  BottomNavigationBarRootItem({
    @required this.routeName,
    @required this.nestedNavigator,
    @required this.bottomNavigationBarItem,
  });
}

abstract class NestedNavigator extends StatelessWidget {
  final GlobalKey<NavigatorState> navigatorKey;

  NestedNavigator({Key key, @required this.navigatorKey}) : super(key: key);
}

class HomeNavigator extends NestedNavigator {
  HomeNavigator({Key key, @required GlobalKey<NavigatorState> navigatorKey})
      : super(
    key: key,
    navigatorKey: navigatorKey,
  );

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      initialRoute: '/',
      onGenerateRoute: (RouteSettings settings) {
        WidgetBuilder builder;
        switch (settings.name) {
          case '/':
            builder = (BuildContext context) => HomePage();
            break;
          case '/home/1':
            builder = (BuildContext context) => HomeSubPage();
            break;
          default:
            throw Exception('Invalid route: ${settings.name}');
        }
        return MaterialPageRoute(
          builder: builder,
          settings: settings,
        );
      },
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Page'),
      ),
      body: Center(
        child: RaisedButton(
          onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => HomeSubPage())),
          child: Text('Open Sub-Page'),
        ),
      ),
    );
  }
}

class HomeSubPage extends StatefulWidget {
  const HomeSubPage({Key key}) : super(key: key);

  @override
  _HomeSubPageState createState() => _HomeSubPageState();
}

class _HomeSubPageState extends State<HomeSubPage> with AutomaticKeepAliveClientMixin{
  @override
  // implement wantKeepAlive
  bool get wantKeepAlive => true;


  String _text;

  @override
  void initState() {
    _text = 'Click me';
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Sub Page'),
      ),
      body: Center(
        child: RaisedButton(
          onPressed: () => setState(() => _text = 'Clicked'),
          child: Text(_text),
        ),
      ),
    );
  }

}

/* convert it to statfull so i can use AutomaticKeepAliveClientMixin to avoid disposing tap */

class SettingsPage extends StatefulWidget {
  @override
  _SettingsPageState createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> with AutomaticKeepAliveClientMixin{

  @override
  // implement wantKeepAlive
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Settings Page'),
      ),
      body: Container(
        child: Center(
          child: Text('Settings Page'),
        ),
      ),
    );
  }

}
4
  • 3
    Yes yes yes ! I just copied your code and that's exactly what I wanted ! This evening I will have time to dig into your code :) Thank you so much ! This problem has bugged me for so long now ! Commented Mar 9, 2020 at 7:56
  • 1
    @DennisSzymanski i feel you ^_^ , i remember i spent a 8 nights to solve it , please share us your code if you made any improvement on it :)
    – abdalmonem
    Commented Mar 9, 2020 at 8:38
  • 1
    When I try to add a 3rd page I get the error: NoSuchMethodError: The getter 'focusScopeNode' was called on null. Does anyone know why?
    – Zog
    Commented Aug 30, 2020 at 14:18
  • @Zog did you change tabController length parameter to 3 ? if so, also check that you add another declaration item to navStack to be 3 "null"s let me know if that help
    – abdalmonem
    Commented Dec 20, 2021 at 10:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.