Bottom navigation which state is saved when clicks in item in the list and switches between bottom tabs.

Sardor Islomov
Flutter Community
Published in
9 min readNov 3, 2020

This will be story for implementing FloatingAction with BottomNavigation in Flutter. So, the problem is creating BottomNavigation with 4 tab items with centered FloatActionButton with right spacing. In addition to that when user clicks some item in the section it opens detail of that item and state of main_screen will be saved or when users switches between tabs it has to save state for current tab. Final output will look like this.

Final output will look like this. Overall we will have 4 sections. Profile section will be avatar of the current logged in user.

  • Home section
  • Discovery section
  • Notification section
  • Profile section

Steps to achieve this sort of UI follows like this and good state management, we need following classes:

  1. app_navigator.dart which helps us to build screens and navigate between them without losing state.
  2. bottom_navigation.dart module which designs actual UI on bottom navigation.
  3. main_screen.dart file which consists app_navigator.dart and bottom_navigation in them.
  4. Other dart files to show UI such as: home_screen, discover_screen, notification_screen and profile_screen.

Let’s do the job

Building AppNavigator module

Quick reminder, AppNavigator is just simple dart file which helps us to create navigation when user do some action and navigates to it.

I am creating app_navigator file. You can name what ever you want.

class AppNavigator extends StatelessWidget{

@override
Widget build(BuildContext context) {
return Container();
}

}

Nothing fancy here. Let’s add two main methods:

_buildRootWidget()
_buildDetailWidget()

buildRootWidget will build main widgets in main_screen. In case of buildDetailWidget gonna build detailWidgets from rootWidget. I know it is confusing let’s see them in action. But before adding this methods let’s add some helper properties for AppNavigator such as:navigatorKey and tabItem. NavigatorKey is type of GlobalKey<NavigatorState> for saving state. TabItem will be our own class.

class AppNavigator extends StatelessWidget{
AppNavigator({this.navigatorKey, this.tabItem});

final GlobalKey<NavigatorState> navigatorKey;
final TabItem tabItem;

Widget _buildRootWidget(BuildContext context) {
if (tabItem == TabItem.home) {
return new HomeScreen();
} else if (tabItem == TabItem.discovery) {
return new DiscoverScreen();
} else if (tabItem == TabItem.notifications) {
return new NotificationScreen();
} else {
return new ProfileScreen();
}
}

Widget _buildDetailWidget(BuildContext context, dynamic item) {
return Container();
}


@override
Widget build(BuildContext context) {
return Container();
}

}

As you can see here I have already added HomeScreen, DiscoverScreen, NotificationScreen, and ProfileScreen. These are all simple screens which extends from StatelessWidget or StatefulWidget based on your needs.

TabItem is our own class which is enum

enum TabItem { home, discovery, notifications, profile }

Last thing for AppNavigator is routeBuilders. It is method which build for us screens which we call it as a route. It will build for Flutter rootWidget or detailWidget based context. Simple method for routeBuilder in AppNavigator will look like this. Before implementing routeBuilders you have to define your routes for whole application. For my case it has tabItem which we call it as AppNavigatorRoutes.root and detail of the root which is AppNavigatorRoutes.detail. Route builders method will manage these two navigations based on context.

Map<String, WidgetBuilder> _routeBuilders(BuildContext context,
{dynamic item}) {
return {
AppNavigatorRoutes.root: (context) => _buildRootWidget(context),
AppNavigatorRoutes.detail: (context) => _buildDetailWidget(context, item)
};
}

Finally we can build our Navigator widget for AppNavigator widget. You have to return Navigator widget in build method of AppNavigator and define settings for whole routes of the application.

@override
Widget build(BuildContext context) {
final routeBuilders = _routeBuilders(context);
return Navigator(
key: navigatorKey,
initialRoute: AppNavigatorRoutes.root,
onGenerateRoute: (routeSettings) {
return CupertinoPageRoute(
builder: (context) => routeBuilders[routeSettings.name](context),
);
},
);
}

As you can see here we build Navigator widget for AppNavigator class and it has properties such as key for saving state, initialRoute for showing first screen, onGenerateRoute it is triggered when some action happened in rootWidget.

Overall AppNavigator module will look like this.

class AppNavigatorRoutes {
static const String root = '/';
static const String detail = '/detail';
}

class AppNavigator extends StatelessWidget{
AppNavigator({this.navigatorKey, this.tabItem});

final GlobalKey<NavigatorState> navigatorKey;
final TabItem tabItem;

Map<String, WidgetBuilder> _routeBuilders(BuildContext context,
{dynamic item}) {
return {
AppNavigatorRoutes.root: (context) => _buildRootWidget(context),
AppNavigatorRoutes.detail: (context) => _buildDetailWidget(context, item)
};
}

Widget _buildRootWidget(BuildContext context) {
if (tabItem == TabItem.home) {
return new HomeScreen();
} else if (tabItem == TabItem.discovery) {
return new DiscoverScreen();
} else if (tabItem == TabItem.notifications) {
return new NotificationScreen();
} else {
return new ProfileScreen();
}
}

Widget _buildDetailWidget(BuildContext context, dynamic item) {
return Container();
}


@override
Widget build(BuildContext context) {
final routeBuilders = _routeBuilders(context);
return Navigator(
key: navigatorKey,
initialRoute: AppNavigatorRoutes.root,
onGenerateRoute: (routeSettings) {
return CupertinoPageRoute(
builder: (context) => routeBuilders[routeSettings.name](context),
);
},
);
}

}

Building BottomNavigation for UI mainly

Nothing fancy, just create BottomNavigation which extends from StatelessWidget.

class BottomNavigation extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container();
}

}

It has two main properties such as: “currentTab” from name you can understand that it holds current Tab of the app and onSelectedTab method which will be used in main_screen.

BottomNavigation({this.currentTab, this.onSelectTab});

final TabItem currentTab;
final ValueChanged<TabItem> onSelectTab;

We will be adding 4 helper methods inside BottomNavigation. Main purpose of those methods is simply to build tabs with icons.

Widget _homeTab() =>
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
child: IconButton(
icon: Icon(Icons.home),
color: _colorTabMatching(item: TabItem.home),
onPressed: () {
onSelectTab(
TabItem.values[0],
);
}),
);

Widget _discoverTab() =>
IconButton(
icon: Icon(Icons.fiber_new),
color: _colorTabMatching(item: TabItem.discovery),
onPressed: () {
onSelectTab(
TabItem.values[1],
);
}
);

Widget _notificationsTab() => IconButton(
icon: Icon(Icons.notifications),
color: _colorTabMatching(item: TabItem.notifications),
onPressed: () {
onSelectTab(
TabItem.values[2],
);
});

Widget _profileSettingsTab() => IconButton(
icon: Icon(Icons.person),
color: _colorTabMatching(item: TabItem.profile),
onPressed: () {
onSelectTab(
TabItem.values[2],
);
});

Color _colorTabMatching({TabItem item}) {
return currentTab == item
? Colors.blue
: Colors.blue.withOpacity(0.25);
}

_colorTabMathing method for changing color from active to inactive of current tab in BottomNavigation.

Finally we can add all those above methods in a build method of BottomNavigation widget.

@override
Widget build(BuildContext context) {
return BottomAppBar(
shape: CircularNotchedRectangle(),
child: Container(
height: 56,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_homeTab(),
_discoverTab(),
SizedBox(width: 50), // The dummy child
_notificationsTab(),
_profileSettingsTab()
],
),
));
}

Simple BottomAppBar from flutter. You can ask why we need SizedBox() widget in the center of BottomAppBar? It is because of FloatingActionButton. If we don’t put SizedBox then FloatingActionButton will overlap with notificationsTab.

Overall code for BottomNavigation will look like this:

enum TabItem { home, discovery, notifications, profile }

class BottomNavigation extends StatelessWidget{
BottomNavigation({this.currentTab, this.onSelectTab});

final TabItem currentTab;
final ValueChanged<TabItem> onSelectTab;


@override
Widget build(BuildContext context) {
return BottomAppBar(
shape: CircularNotchedRectangle(),
child: Container(
height: 56,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_homeTab(),
_discoverTab(),
SizedBox(width: 50), // The dummy child
_notificationsTab(),
_profileSettingsTab()
],
),
));
}


Widget _homeTab() =>
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
child: IconButton(
icon: Icon(Icons.home),
color: _colorTabMatching(item: TabItem.home),
onPressed: () {
onSelectTab(
TabItem.values[0],
);
}),
);

Widget _discoverTab() =>
IconButton(
icon: Icon(Icons.fiber_new),
color: _colorTabMatching(item: TabItem.discovery),
onPressed: () {
onSelectTab(
TabItem.values[1],
);
}
);

Widget _notificationsTab() => IconButton(
icon: Icon(Icons.notifications),
color: _colorTabMatching(item: TabItem.notifications),
onPressed: () {
onSelectTab(
TabItem.values[2],
);
});

Widget _profileSettingsTab() => IconButton(
icon: Icon(Icons.person),
color: _colorTabMatching(item: TabItem.profile),
onPressed: () {
onSelectTab(
TabItem.values[2],
);
});

Color _colorTabMatching({TabItem item}) {
return currentTab == item
? Colors.blue
: Colors.blue.withOpacity(0.25);
}

}

Building main_screen with the help of bottom_navigation and app_navigator modules.

main_screen.dart file will called from main.dart file when app launches first. It will be simple StatefulWidget. Which look like this.

class MainScreen extends StatefulWidget{
@override
State<StatefulWidget> createState() {
return _MainScreenState();
}

}

class _MainScreenState extends State<MainScreen>{
@override
Widget build(BuildContext context) {
return Scaffold();
}

}

MainScreen’s job is to save state for each tab. Based on your needs you can also add other methods and properties. I always try to avoid add tab related methods inside MainScreen. Let’s add our properties.

TabItem _currentTab = TabItem.home;

Map<TabItem, GlobalKey<NavigatorState>> _navigatorKeys = {
TabItem.home: GlobalKey<NavigatorState>(),
TabItem.discovery: GlobalKey<NavigatorState>(),
TabItem.notifications: GlobalKey<NavigatorState>(),
TabItem.profile: GlobalKey<NavigatorState>(),
};

From name you can infer that _currentTab is item which defines current screen for bottomNavigation. _navigatorKeys saves state for each Navigators based on tabItems in hashMap. You can read more about GlobalKey here. Basically it helps us to save state when we switch between tabs or open detail screen from one of the tab screen.

We have to define in main_screen.dart file our _selectTab method which we will pass it to the BottomNavigation class. From the name of method you can understand that it will help us to change _currentTab item state and _navigatorKeys properties.

void _selectTab(TabItem tabItem) {
if (tabItem == _currentTab) {
// pop to first route
_navigatorKeys[tabItem].currentState.popUntil((route) => route.isFirst);
} else {
setState(() => _currentTab = tabItem);
}
}

Next method in main_screen will be _buildOffstageNavigator. The idea behind this method is that it will help us to create AppNavigator widget and manages tabItem widget visibility in main_screen.

Widget _buildOffstageNavigator(TabItem tabItem) {
return Offstage(
offstage: _currentTab != tabItem,
child: AppNavigator(
navigatorKey: _navigatorKeys[tabItem],
tabItem: tabItem));
}

Based on documentation Offstage, it helps to work with visibility of tab widgets in main screen.

/// [Offstage] can be used to measure the dimensions of a widget without
/// bringing it on screen (yet). To hide a widget from view while it is not
/// needed, prefer removing the widget from the tree entirely rather than
/// keeping it alive in an [Offstage] subtree.

Last method in main_screen will be to implement build method of widget class. Basically we create Scaffold Element and add to its body stack of widgets using our _buildOffstageNavigator method. For bottomNavigationBar we add our own implementation of BottomNavigation. Lastly we center our Floating action button.

@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: <Widget>[
_buildOffstageNavigator(TabItem.home),
_buildOffstageNavigator(TabItem.discovery),
_buildOffstageNavigator(TabItem.notifications),
_buildOffstageNavigator(TabItem.profile),
]),
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
);
}

I would like to add Floating action button in center using property called:FloatingActionButtonLocation

floatingActionButtonLocation:
FloatingActionButtonLocation.centerDocked,

Adding FloatingActionButton is simple. Just add FloatingActionButton widget to property of Scaffold called floatingActionButton.

floatingActionButton:FloatingActionButton(
backgroundColor: Colors.transparent,
elevation: 0,
onPressed: () {

},
child: Container(
height: 100,
width: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
child: Icon(
Icons.dashboard,
color: Colors.white,
size: 35,
),
),
)

So overall code for main_screen will look like this.

class MainScreen extends StatefulWidget{
@override
State<StatefulWidget> createState() {
return _MainScreenState();
}

}

class _MainScreenState extends State<MainScreen>{
TabItem _currentTab = TabItem.home;

Map<TabItem, GlobalKey<NavigatorState>> _navigatorKeys = {
TabItem.home: GlobalKey<NavigatorState>(),
TabItem.discovery: GlobalKey<NavigatorState>(),
TabItem.notifications: GlobalKey<NavigatorState>(),
TabItem.profile: GlobalKey<NavigatorState>(),
};

void _selectTab(TabItem tabItem) {
if (tabItem == _currentTab) {
// pop to first route
_navigatorKeys[tabItem].currentState.popUntil((route) => route.isFirst);
} else {
setState(() => _currentTab = tabItem);
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(children: <Widget>[
_buildOffstageNavigator(TabItem.home),
_buildOffstageNavigator(TabItem.discovery),
_buildOffstageNavigator(TabItem.notifications),
_buildOffstageNavigator(TabItem.profile),
]),
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
floatingActionButtonLocation:
FloatingActionButtonLocation.centerDocked,
floatingActionButton:FloatingActionButton(
backgroundColor: Colors.transparent,
elevation: 0,
onPressed: () {

},
child: Container(
height: 100,
width: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
child: Icon(
Icons.dashboard,
color: Colors.white,
size: 35,
),
),
));
}

Widget _buildOffstageNavigator(TabItem tabItem) {
return Offstage(
offstage: _currentTab != tabItem,
child: AppNavigator(
navigatorKey: _navigatorKeys[tabItem],
tabItem: tabItem));
}

}

Overall project folder will look like this. I added empty screens such as:home_screen, discover_screen, notification_screen, profile_screen.

When we run the project it should look like this.

Let’s add some list widget to home and discover screens and switch between them to so if state of each screen

From video you can see that state is saved when user switches between tabs. That was our goal. Let’s add detail screen for list item click or screen for floatingactionbutton click.

I added two empty widgets: dashboard_screen, home_screen_detail. I am navigating to them using Page route. I used here MaterialPageRoute, you can use whatever makes more sense to your project.

This is my home_screen class.

class HomeScreen extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _HomeScreenState();
}
}

class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Container(
child: ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return SizedBox(
height: 70,
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => new HomeScreenDetail(),
),
);
},
child: Center(
child: Text(
"Home screen item:$index",
style: TextStyle(fontSize: 16),
),
),
),
);
},
));
}
}

Final output to our project will be as following:

I know my tutorial is a bit long but it has all things there. I wasn’t able to find out there bottom navigation with saving state with floatingactionbutton. Hope it will be helpful. You can find whole project code on my github account.

--

--