Loading...

STORIES

Flutter Routing with MobX

Getting routing in Flutter right comes with many pitfalls. One of the best examples I've seen comes from Andrea Bizzotto, in his article Flutter Case Study: Multiple Navigators with BottomNavigationBar, with credits to Brian Egan for his idea to use a Stack with Offstage widgets.

Let's take that a step further and use MobX for Dart for managing routing state, and let's use Dart's OO capabilities to preserve the state of the stacked offstage widgets, instead of Flutter's Navigator. This way we can avoid having to write custom route transitions to ensure consistency between iOS and Android, and this should result in a clean and simplified solution.

Flutter Routing with MobX visual

Note: If you want to take advantage of everything that comes with Flutter's Navigator, do not use this approach.

Right, we're creating an app with bottom navigation, that works with three stacked content route widgets, that display onstage (or in view) when needed, and on those stacked route widgets we'll add landing view widgets, and some additional view widgets. The source code for this is available on GitHub.

Install Flutter and create the app:

flutter create myapp

Start by adding the MobX dependencies in pubspec.yaml:

...
dependencies:
...
  mobx: ^0.1.3
  flutter_mobx: ^0.1.1
...
dev_dependencies:
...
  build_runner: ^1.2.5
  mobx_codegen: ^0.1.1
...

Add global content navigation enums by adding lib/content_navigation.dart:

enum ContentItem { 
  home, 
  more_content, 
  and_then_some
}

enum HomeRoute {
  landing,
  secondary,
  tertiary
}

enum MoreContentRoute {
  landing
}

enum AndThenSomeRoute {
  landing
}

We'll be using these throughout the app.

Then create a state store by adding lib/state_store.dart:

import 'package:mobx/mobx.dart';
import 'package:myapp/content_navigation.dart';

part 'state_store.g.dart';

class StateStore = _StateStore with _$StateStore;

abstract class _StateStore implements Store {
@observable
ContentItem currentContentItem = ContentItem.home;
  @observable
  HomeRoute currentHomeRoute = HomeRoute.landing;

@action
void setHomeRoute(HomeRoute contentRoute) {
    currentContentItem = ContentItem.home;
    currentHomeRoute = contentRoute;
  }
  @action
  void setMoreContentRoute() {
    currentContentItem = ContentItem.more_content;
  }
  @action
  void setAndThenSomeRoute() {
    currentContentItem = ContentItem.and_then_some;
  }
}

This creates two observables – one for indicating the active main content item, the Offstage widget on the Stack that is currently visible, currentContentItem, and the other, currentHomeRoute, for indicating the current active home view – and three actions.

Add the bottom navigation widget by adding lib/bottom_navigation.dart:

part of 'main.dart';

class BottomNavigation extends StatelessWidget {
  Color _isSelected({ContentItem item}) {
    return stateStore.currentContentItem == item ? Colors.black : Colors.grey;
  }

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      type: BottomNavigationBarType.fixed,
      items: [
        BottomNavigationBarItem(
          icon: Icon(Icons.web_asset),
          title: Text('Home', style: TextStyle(color: _isSelected(item: stateStore.currentContentItem)),
        )),
        BottomNavigationBarItem(
          icon: Icon(Icons.web_asset),
          title: Text('More Content', style: TextStyle(color: _isSelected(item: stateStore.currentContentItem)),
        )),
        BottomNavigationBarItem(
          icon: Icon(Icons.web_asset),
          title: Text('And Then Some', style: TextStyle(color: _isSelected(item: stateStore.currentContentItem)),
        )),
      ],
      onTap: (index) {
        switch (index) {
          case 1:
            stateStore.setMoreContentRoute();
            break;
          case 2:
            stateStore.setAndThenSomeRoute();
            break;
          default:
            stateStore.setHomeRoute(stateStore.currentHomeRoute);
            break;
        }
      },
    );
  }
}

Here we create three navigation items: Home, More Content and And Then Some. Tapping these will mutate the observables defined in our state store, mainly the currentContentItem. We will use this to display the active stack item.

Add the three stacked content route widgets by adding lib/content_routes.dart:

part of 'main.dart';

class HomeContent extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => HomeContentState();
}

class HomeContentState extends State<HomeContent> {
  HomeLandingView landingView;
  HomeSecondaryView secondaryView;
  HomeTertiaryView tertiaryView;

  @override
  initState() {
    super.initState();
    landingView = new HomeLandingView();
    secondaryView = new HomeSecondaryView();
    tertiaryView = new HomeTertiaryView();
  }

  @override
  Widget build(BuildContext context) {
    return stateStore.currentHomeRoute == HomeRoute.landing ? landingView
    : stateStore.currentHomeRoute == HomeRoute.secondary ? secondaryView
    : stateStore.currentHomeRoute == HomeRoute.tertiary ? tertiaryView
    : Container();
  }
}

class MoreContent extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => MoreContentState();
}

class MoreContentState extends State<MoreContent> {
  MoreContentLandingView landingView;

  @override
  initState() {
    super.initState();
    landingView = new MoreContentLandingView();
  }

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

class AndThenSome extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => AndThenSomeState();
}

class AndThenSomeState extends State<AndThenSome> {
  AndThenSomeLandingView landingView;

  @override
  initState() {
    super.initState();
    landingView = new AndThenSomeLandingView();
  }

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

Here we define three widgets that will be added to the stack. Each of these widgets contain view widgets. The home content widget contains three views, and displays the view that matches the currentHomeRoute observable. The other two content widgets contain only landing views, but could contain any number of views.

Add the view widgets by adding lib/content_views.dart:

part of 'main.dart';

class HomeLandingView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Landing'),
      ),
      body: Container(
        margin: EdgeInsets.all(50.0),
        child: Column(
          children: [
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'),
            RaisedButton(
              child: const Text('Secondary home content'),
              onPressed: () => stateStore.setHomeRoute(HomeRoute.secondary),
            )
          ]
        )
      )
    );
  }
}

class HomeSecondaryView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Secondary'),
      ),
      body: Container(
        margin: EdgeInsets.all(50.0),
        child: Column(
          children: [
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.'),
            RaisedButton(
              child: const Text('Tertiary home content'),
              onPressed: () => stateStore.setHomeRoute(HomeRoute.tertiary),
            ),
            RaisedButton(
              child: const Text('Back'),
              onPressed: () => stateStore.setHomeRoute(HomeRoute.landing),
            )
          ]
        )
      )
    );
  }
}

class HomeTertiaryView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home Tertiary'),
      ),
      body: Container(
        margin: EdgeInsets.all(50.0),
        child: Column(
          children: [
            Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'),
            RaisedButton(
              child: const Text('Back'),
              onPressed: () => stateStore.setHomeRoute(HomeRoute.secondary),
            )
          ]
        )
      )
    );
  }
}

class MoreContentLandingView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('More Content Landing'),
      ),
      body: Container(
        margin: EdgeInsets.all(50.0),
        child: Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.')
      )
    );
  }
}

class AndThenSomeLandingView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('And Then Some Landing'),
      ),
      body: Container(
        margin: EdgeInsets.all(50.0),
        child: Text('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.')
      )
    );
  }
}

Note the three home content views mutating the observables by calling the setHomeRoute action.

Next we'll create our app. Update lib/main.dart:

// General Flutter packages
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';

// MobX state management
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:myapp/state_store.dart';

// App components
import 'package:myapp/content_navigation.dart';
part 'package:myapp/bottom_navigation.dart';

// App routers
part 'package:myapp/content_routes.dart';

// App views
part 'package:myapp/content_views.dart';

// Globals
final StateStore stateStore = StateStore();

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: new App(),
      ),
    );
  }
}

class App extends StatelessWidget {
  Widget _buildOffstageContent(ContentItem contentItem) {
    return Offstage(
      offstage: stateStore.currentContentItem != contentItem,
      child: contentItem == ContentItem.home ? HomeContent()
      : contentItem == ContentItem.more_content ? MoreContent()
      : contentItem == ContentItem.and_then_some ? AndThenSome()
      : Container(),
    );
  }

  @override
  Widget build(BuildContext context) => Observer(
    builder: (_) {
      return Scaffold(
        body: Stack(children: <Widget>[
          _buildOffstageContent(ContentItem.home),
          _buildOffstageContent(ContentItem.more_content),
          _buildOffstageContent(ContentItem.and_then_some),
        ]),
        bottomNavigationBar: BottomNavigation(),
      );
    }
  );
}

This is where the three Offstage widgets are created, and displayed based on the app's state. This is also where the Observer widget is declared. This is an important step, otherwise the changes will not be picked up by MobX, and so our app's state will not change.

Finally, install the dependencies, run the MobX code generation builder, and run the app:

flutter packages get
flutter packages pub run build_runner build
flutter run

This should provide a solid starting point for your Flutter app's navigation.