Flutter application templates and bottom navigation using provider and MobX
Application developers are accustomed to having using an IDE to create a brand new application using one of the provide templates as a starting point. For example, when creating an Android application...
UPDATE 5/6/2019: the code snippets have been updated to make use of const where possible with some notes added
UPDATE 19/6/2019: this post has been updated since it was originally written. The DestinationsStore
class was previously called AppStore
. The renaming is to better align with domain vocabulary
UPDATE 30/6/2019: I've updated some of the code snippets and screenshots here to match the latest updates
Application developers are accustomed to having using an IDE to create a brand new application using one of the provide templates as a starting point. For example, when creating an Android application, developers can choose from options like a bottom navigation activity, master/detail flow etc. Some ecosystems also provide the ability create an application using templates created by the community. These are generally done so that developers get to use their preferred library of choice for structuring their application. For example, if you're developing a Xamarin.Forms application in Visual Studio and prefer to use Prism to implement the MVVM pattern, then one can install the Prism Template Pack that would add project templates to their Visual Studio installation. This would allow developers to pick the template that would create a Xamarin.Forms applications with Prism installed and view models are then implemented using Prism's APIs. Currently, Flutter doesn't provide similar functionality though it is possible to use the command to create an app from one the samples in the docs. In this post, I'll be introducing my attempt to create an application template that uses material design and combines using the provider and MobX for state management. The application will have a bottom navigation bar that allows users to change destinations (note: destinations are what they're referred to material design specification but I will use this interchangeably refer to them as pages as well)
The following are screenshots from the application
The application is similar to the one created by Android Studio when creating an Android application with a bottom navigation activity. The difference here is that each page has its own counter that should be familiar to Flutter developers as the tooling will default to creating a counter application out of the box.
Each page is associated with a data store to keep track of the counter and an action to increment the counter. A data store for the destinations is used to track the available destinations that the user can select and which one is currently selected
import 'package:mobx/mobx.dart';
import '../constants/enums.dart';
part 'destinations_store.g.dart';
class DestinationsStore = DestinationsStoreBase with _$DestinationsStore;
abstract class DestinationsStoreBase with Store {
static const List<Destination> destinations = Destination.values;
@observable
int selectedDestinationIndex = destinations.indexOf(Destination.Home);
@computed
Destination get selectedDestination => destinations[selectedDestinationIndex];
@action
void selectDestination(int index) {
selectedDestinationIndex = index;
}
}
Within the data store, the selected destination defaults to the home page. The data store provides an action that updates the currently selected destination and is invoked when the user picks one of the items that are part of bottom navigation bar. The code for the application that relates to this is as follows
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'constants/enums.dart';
import 'constants/keys.dart';
import 'pages/dashboard_page.dart';
import 'pages/home_page.dart';
import 'pages/notifications_page.dart';
import 'pages/settings_page.dart';
import 'services/preferences_service.dart';
import 'stores/dashboard_store.dart';
import 'stores/destinations_store.dart';
import 'stores/home_store.dart';
import 'stores/notifications_store.dart';
import 'stores/settings_store.dart';
void main() async {
final sharedPreferences = await SharedPreferences.getInstance();
runApp(App(sharedPreferences));
}
class App extends StatelessWidget {
const App(this.sharedPreferences, {Key key}) : super(key: key);
final SharedPreferences sharedPreferences;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<DestinationsStore>(
builder: (_) => DestinationsStore(),
),
Provider<HomeStore>(
builder: (_) => HomeStore(),
),
Provider<DashboardStore>(
builder: (_) => DashboardStore(),
),
Provider<NotificationsStore>(
builder: (_) => NotificationsStore(),
),
Provider<PreferencesService>(
builder: (_) => PreferencesService(sharedPreferences),
),
ProxyProvider<PreferencesService, SettingsStore>(
builder: (_, preferencesService, __) =>
SettingsStore(preferencesService),
),
],
child: Consumer<SettingsStore>(
builder: (context, store, _) {
return Observer(
builder: (_) {
return MaterialApp(
title: 'App title',
theme: store.useDarkMode ? ThemeData.dark() : ThemeData.light(),
home: Consumer<DestinationsStore>(
builder: (context, store, _) {
return Observer(
builder: (_) {
return Scaffold(
appBar: AppBar(
title: AppBarTitle(store.selectedDestination),
),
body: SafeArea(
child: PageContainer(
store.selectedDestination,
),
),
bottomNavigationBar: AppBottomNavigationBar(store),
floatingActionButton: store.selectedDestination ==
Destination.Settings
? null
: FloatingActionButton(
key: Keys.incrementButtonKey,
onPressed: () {
switch (store.selectedDestination) {
case Destination.Home:
Provider.of<HomeStore>(context)
.increment();
break;
case Destination.Dashboard:
Provider.of<DashboardStore>(context)
.increment();
break;
case Destination.Notifications:
Provider.of<NotificationsStore>(context)
.increment();
break;
case Destination.Settings:
break;
}
},
child: const Icon(Icons.add),
),
);
},
);
},
),
);
},
);
},
),
);
}
}
class AppBarTitle extends StatelessWidget {
const AppBarTitle(this.destination, {Key key}) : super(key: key);
final Destination destination;
@override
Widget build(BuildContext context) {
switch (destination) {
case Destination.Dashboard:
return const Text('Dashboard', key: Keys.dashboardPageTitleKey);
case Destination.Notifications:
return const Text('Notifications', key: Keys.notificationsPageTitleKey);
case Destination.Settings:
return const Text('Settings', key: Keys.settingsPageTitleKey);
default:
return const Text('Home', key: Keys.homePageTitleKey);
}
}
}
class AppBottomNavigationBar extends StatelessWidget {
const AppBottomNavigationBar(
this.store, {
Key key,
}) : super(key: key);
final DestinationsStore store;
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
key: const Key('bottomNavigationBar'),
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
currentIndex: store.selectedDestinationIndex,
items: DestinationsStoreBase.destinations.map(
(option) {
switch (option) {
case Destination.Home:
return const BottomNavigationBarItem(
icon: Icon(Icons.home),
title: Text('Home'),
);
case Destination.Dashboard:
return const BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
title: Text('Dashboard'),
);
case Destination.Notifications:
return const BottomNavigationBarItem(
icon: Icon(Icons.notifications),
title: Text('Notifications'),
);
case Destination.Settings:
return const BottomNavigationBarItem(
icon: Icon(Icons.settings),
title: Text('Settings'),
);
}
},
).toList(),
onTap: (index) => store.selectDestination(index),
);
}
}
class PageContainer extends StatelessWidget {
const PageContainer(this.destination, {Key key}) : super(key: key);
final Destination destination;
@override
Widget build(BuildContext context) {
switch (destination) {
case Destination.Dashboard:
return const DashboardPage(key: Keys.dashboardPageKey);
case Destination.Notifications:
return const NotificationsPage(key: Keys.notificationsPageKey);
case Destination.Settings:
return const SettingsPage(key: Keys.settingsPageKey);
default:
return const HomePage(key: Keys.homePageKey);
}
}
}
The child
of the Scaffold
widget is what will display the current page via the PageContainer
widget. The Scaffold
widget has a BottomNavigationBar
widget specified and this widget handles when the user taps on an menu item. When this happens, the action to update the selected destination is invoked. If the destination changes, the PageContainer
widget is redrawn at which point since there is an Observer
widget that will react to when the selected destination changes (the selectedDestination
property in the DestinationsStore
class). When the destination is observed to have changed, the selected destination is checked to determine the page that should be rendered. Some approaches like to define all of the pages for the tabs and store them in a list beforehand compared to what's shown above. I prefer the approach I've shown as it keeps widget construction in the build
method, avoids initialising widgets that might not actually be displayed and in my opinion, makes the code is easier to follow.
Now that I've explained the structure behind the application, if this template is of interest to you, check out the GitHub repository here. There's a readme that explains the steps required to use the template to create an application. Updates may be made to the template in the future so if you find it useful, I'd suggest watching the repository for updates.