Flutter: A Better Navigation Using BLoC

How to make your Flutter app navigation stateful so your user doesn’t have to open a new page for every navigation item.

Deep Patel
Capital One Tech
8 min readJul 15, 2020

--

The most important thing you’ll build for your mobile app is probably the navigation. A stable and reliable navigation architecture gives your users a great experience, and it lets them navigate to features without getting lost. As a software engineer specializing in the mobile space, I’ve seen a lot of different ways to create a navigation drawer with Flutter. Most do not include a stateful widget that changes the content the user wants to see without having them navigate away and enter a new page.

gif of blue lefthand menu bar opening over a grey and yellow webpage
This is usually the navigation experience shown to the user when a new route is created per page:

There is always a new page added to the screen so if the user was on Android, they could easily press the back button and they’d be in the previous tab. This is not the expected behavior from the navigation drawer. Instead, the user should stay where they are and your app should be stateful enough to change the content the user sees and update the view accordingly.

So let’s look at how we can achieve this using the BLoC pattern.

gif of dark grey menu bar with profile icon sliding over yellow and grey webpage with grey and yellow buttons
This looks much more natural

What is BLoC?

BLoC stands for business logic component, and is intended to be a single source of truth for a section or feature of your app such as Navigation, authentication, login, profile, etc. The BLoC takes Events and emits States using an async* function that continues to run well before it’s called. Under the hood, the BLoC extends from a Stream, which takes inputs and emits values. Below is the architecture of how it would relate with your application.

flowchart made of yellow and blue circles, orange square, and black arrows and black text

Getting Started

To get started, import the following dependencies into your pubsec.yaml file to add the BLoC dependencies. It’ll give you everything you need to build, and use your BLoC, like the BlocProvider, BlocBuilder, BlocListener, testing capabilities and more.

# bloc dependencies
bloc: ^4.0.0
flutter_bloc: ^4.0.0
bloc_test: ^5.1.0

Then create the folders for your classes you’ll need a file for the bloc, state, event and a well file. The well file is optional but it helps to keep your imports clean.

lib/bloc/navigation_drawer/blocs.dart
lib/bloc/navigation_drawer/nav_drawer_bloc.dart
lib/bloc/navigation_drawer/nav_drawer_event.dart
lib/bloc/navigation_drawer/nav_drawer_state.dart

Event

As mentioned before, the Event is an action that your user might perform that’ll trigger a state change. Events are usually verbs, constants, extend from an abstract class, and have simple names, such as Login, Navigate.

// this import is needed to import NavItem, 
// which we'll use to represent the item the user has selected
import 'nav_drawer_state.dart';
// it's important to use an abstract class, even if you have one
// event, so that you can use it later in your BLoC and or tests
abstract class NavDrawerEvent {
const NavDrawerEvent();
}
// this is the event that's triggered when the user
// wants to change pages
class NavigateTo extends NavDrawerEvent {
final NavItem destination;
const NavigateTo(this.destination);
}

State

The State represents what the user is intended to see. In our case, the state is the page that the user has selected. This will usually be a noun. For example, if you have a Login Bloc, you might make states for Loading, Authenticated, UnAuthenticated, and Error.

// this is the state the user is expected to see
class NavDrawerState {
final NavItem selectedItem;
const NavDrawerState(this.selectedItem);
}
// helpful navigation pages, you can change
// them to support your pages
enum NavItem {
page_one,
page_two,
page_three,
page_four,
page_five,
page_six,
}

Putting it All Together in BLoC

Now we’ll put everything together in our BLoC class. We extend from Bloc:

import 'package:bloc/bloc.dart';
import 'package:nice_nav/bloc/blocs.dart';
class NavDrawerBloc extends Bloc<NavDrawerEvent, NavDrawerState> {// You can also have an optional constructor here that takes
// a repository that you can use later to make network requests
// this is the initial state the user will see when
// the bloc is first created
@override
NavDrawerState get initialState => NavDrawerState(NavItem.page_one);
@override
Stream<NavDrawerState> mapEventToState(NavDrawerEvent event) async* {
// this is where the events are handled, if you want to call a method
// you can yield* instead of the yield, but make sure your
// method signature returns Stream<NavDrawerState> and is async*
if (event is NavigateTo) {
// only route to a new location if the new location is different
if (event.destination != state.selectedItem) {
yield NavDrawerState(event.destination);
}
}
}
}

Optional but Encouraged

// Well for exporting Bloc files
export 'package:nice_nav/bloc/navigation_drawer/nav_drawer_bloc.dart';
export 'package:nice_nav/bloc/navigation_drawer/nav_drawer_event.dart';
export 'package:nice_nav/bloc/navigation_drawer/nav_drawer_state.dart';

Testing Your BLoC

Testing your BLoC is really easy. Use the blocTest method to test it out. You can read more about it here. For the sake of this example, we’ll keep it simple, but you can always add more tests. If your BLoC gets more complex, you can always add more.

Use the build method to build your bloc, you can also mock out calls and make mocks in this method. Use the act method to perform an action on your bloc, such as navigate. In this example, we’re adding the NavigateTo event because we want to test that the block. We’ll use the expect list to validate which states the bloc should emit, in our case we’re expecting NavDrawerState without the initial state of the bloc. Finally, we’ll add the verify method which we’ll use to verify that the bloc’s navigation state is changed to the fifth page. You can do all your verifications in this method so if you expect your bloc to make a request to the network, you can do that here as well.

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nice_nav/bloc/blocs.dart';
main() {
blocTest<NavDrawerBloc, NavDrawerEvent, NavDrawerState>(
'Emits [NavDrawerState] when NavigateTo(NavItem.page_five) is added',
build: () async => NavDrawerBloc(),
act: (bloc) async => bloc.add(NavigateTo(NavItem.page_five)),
expect: [isA<NavDrawerState>()],
verify: (bloc) async {
expect(bloc.state.selectedItem, NavItem.page_five);
});
}

And that’s it! Run your test! We’re ready to use the Navigation bloc in our app!

Navigation

Now that we have our navigation drawer bloc, we’ll use it to show the user the content for each page based on the state of the bloc. We can do this by making a container that’ll render the content, provide the bloc, and then build based on the states. Unlike the old approach, we’ll only need one route for this method.

MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Nice Navigation Demo',
theme: ThemeData(
primarySwatch: Colors.amber,
scaffoldBackgroundColor: CoreColors.background),
home: MainContainerWidget(),
);

MainContainerWidget

The main container widget will act as the navigation page that the user will see and interact with. You can use the BLoC by providing it to the children, like so

@override
Widget build(BuildContext context) => BlocProvider<NavDrawerBloc>(
create: (BuildContext context) => NavDrawerBloc(),
child: BlocBuilder<NavDrawerBloc, NavDrawerState>(
builder: (BuildContext context, NavDrawerState state) => Scaffold(
drawer: NavDrawerWidget("Joe Shmoe", "shmoe@joesemail.com"),
appBar: AppBar(
title: Text(_getTextForItem(state.selectedItem)),
),
body: _bodyForState(state),
floatingActionButton: _getFabForItem(state.selectedItem)),
),
);

DrawerWidget

Now we’ll make the NavDrawerWidget which is the content of the navigation drawer. This will have the list of items that the user can navigate between. Since we’re using the BLoC pattern we’re able to get the BLoC from the providing widget. We’ll make the list, and handle any button clicks here.

To add events to your BLoC, you can get the provider by calling the following.

NOTE — make sure that the context you use and pass to of is a child of BlocProvider, otherwise, this will throw an error and you won’t be able to get the BLoC

BlocProvider.of<NavDrawerBloc>(context).add(NavigateTo(item));

Below is the stateless DrawerWidget

class NavDrawerWidget extends StatelessWidget {
final String accountName;
final String accountEmail;
final List<_NavigationItem> _listItems = [
_NavigationItem(true, null, null, null),
_NavigationItem(false, NavItem.page_one, "First Page", Icons.looks_one),
_NavigationItem(false, NavItem.page_two, "Second Page", Icons.looks_two),
_NavigationItem(false, NavItem.page_three, "Third Page", Icons.looks_3),
_NavigationItem(false, NavItem.page_four, "Fourth Page", Icons.looks_4),
_NavigationItem(false, NavItem.page_five, "Fifth Page", Icons.looks_5),
_NavigationItem(false, NavItem.page_six, "Sixth Page", Icons.looks_6),
];
NavDrawerWidget(this.accountName, this.accountEmail); @override
Widget build(BuildContext context) => Drawer(
// Add a ListView to the drawer. This ensures the user can scroll
// through the options in the drawer if there isn't enough vertical
// space to fit everything.
child: Container(
color: CoreColors.background,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _listItems.length,
itemBuilder: (BuildContext context, int index) =>
BlocBuilder<NavDrawerBloc, NavDrawerState>(
builder: (BuildContext context, NavDrawerState state) =>
_buildItem(_listItems[index], state),
)),
));
Widget _buildItem(_NavigationItem data, NavDrawerState state) => data.header
// if the item is a header return the header widget
? _makeHeaderItem()
// otherwise build and return the default list item
: _makeListItem(data, state);
Widget _makeHeaderItem() => UserAccountsDrawerHeader(
accountName: Text(accountName, style: TextStyle(color: Colors.white)),
accountEmail: Text(accountEmail, style: TextStyle(color: Colors.white)),
decoration: BoxDecoration(color: Colors.blueGrey),
currentAccountPicture: CircleAvatar(
backgroundColor: Colors.white,
foregroundColor: Colors.amber,
child: Icon(
Icons.person,
size: 54,
),
),
);
Widget _makeListItem(_NavigationItem data, NavDrawerState state) => Card(
color: data.item == state.selectedItem
? CoreColors.selectedNavItemRow
: CoreColors.background,
shape: ContinuousRectangleBorder(borderRadius: BorderRadius.zero),
// So we see the selected highlight
borderOnForeground: true,
elevation: 0,
margin: EdgeInsets.zero,
child: Builder(
builder: (BuildContext context) => ListTile(
title: Text(
data.title,
style: TextStyle(
color: data.item == state.selectedItem
? Colors.blue
: Colors.blueGrey,
),
),
leading: Icon(
data.icon,
// if it's selected change the color
color: data.item == state.selectedItem
? Colors.blue
: Colors.blueGrey,
),
onTap: () => _handleItemClick(context, data.item),
),
),
);
void _handleItemClick(BuildContext context, NavItem item) {
BlocProvider.of<NavDrawerBloc>(context).add(NavigateTo(item));
Navigator.pop(context);
}
}
// helper class used to represent navigation list items
class _NavigationItem {
final bool header;
final NavItem item;
final String title;
final IconData icon;
_NavigationItem(this.header, this.item, this.title, this.icon);
}

Great! we’re almost done…

Animate!

For an added flair you can tweak the MainContainerWidget to animate the transition of your content. You can do this by using the BlocListener and listening to the state changes, then calling setState inside your widget to change the content and AnimatedSwitcher will take care of the rest! Don’t forget to handle the initial state of the bloc because BlocListener does not call the listener method for the bloc’s initial state!

For added customization, you can also define duration, switchInCurve, switchOutCurve the Curves library which is provided by the Flutter SDK so you don’t even have to add another dependency to your app!

@override
void initState() {
super.initState();
_bloc = NavDrawerBloc();
_content = _getContentForState(_bloc.state);
}
@override
Widget build(BuildContext context) => BlocProvider<NavDrawerBloc>(
create: (BuildContext context) => _bloc,
child: BlocListener<NavDrawerBloc, NavDrawerState>(
listener: (BuildContext context, NavDrawerState state) {
setState(() {
_content = _getContentForState(state);
});
},
child: BlocBuilder<NavDrawerBloc, NavDrawerState>(
builder: (BuildContext context, NavDrawerState state) => Scaffold(
drawer: NavDrawerWidget("Joe Shmoe", "shmoe@joesemail.com"),
appBar: AppBar(
title: Text(_getTextForItem(state.selectedItem)),
),
body: AnimatedSwitcher(
switchInCurve: Curves.easeInExpo,
switchOutCurve: Curves.easeOutExpo,
duration: Duration(milliseconds: 300),
child: _content,
),
floatingActionButton: _getFabForItem(state.selectedItem)),
),
));

Voiala! you have a beautiful app navigating between pages, and it has animations!

gif of dark grey menu bar with profile icon sliding over yellow and grey webpage with grey and yellow buttons

DISCLOSURE STATEMENT: © 2020 Capital One. Opinions are those of the individual author. Unless noted otherwise in this post, Capital One is not affiliated with, nor endorsed by, any of the companies mentioned. All trademarks and other intellectual property used or displayed are property of their respective owners.

--

--