Flutter Navigation with Redux

Photo of Marcin Oziemski

Marcin Oziemski

Updated Dec 6, 2024 • 9 min read
andrew-neel-133200-unsplash

In this article, I will describe how I handled navigation in the app with Redux.

You could of course ask why I’ve done this and the answer would be mainly because of easier debugging and logging to analytics. However, this also enables more complicated navigation cases like “navigate to this position only if…”, and it would be separated from other logic layers.

What you should know to get the most from this article?


For sure know basics about Flutter and be familiar with Redux (or MVI architecture).

So let’s start!

App

To show you how I’ve done it I will use an example of the application for managing some games (like tennis or squash).

On the left, you can see that we have some main view with a list of games. Bottom navigation bar have also 3 icons that lead to some stub view and a floating action button which open a new screen with button to show a dialog.

AppRoutes

Each of these screens is represented by a String.

class AppRoutes {
  static const home = "/";
  static const addGame = "/addGame";
  static const history = "/history";
  static const money = "/money";
  static const profile = "/profile";
}

Which we use in our main Widget.

main()

Ok so firstly those routes names we use in the _getRoute(…) method to return CustomRoute with proper screen Widget on a route change.

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

final GlobalKey<NavigatorState> navigatorKey = new GlobalKey<NavigatorState>();

class MyApp extends StatelessWidget {
  final store = Store<AppState>(appReducer,
      initialState: AppState.loading(),
      middleware: createNavigationMiddleware());

  MaterialPageRoute _getRoute(RouteSettings settings) {
    switch (settings.name) {
      case AppRoutes.home:
        return MainRoute(HomePage(), settings: settings);
      case AppRoutes.addGame:
        return FabRoute(NewGame(), settings: settings);
      default:
        return MainRoute(StubScreen(), settings: settings);
    }
  }

  @override
  Widget build(BuildContext context) {
    return StoreProvider(
      store: store,
      child: MaterialApp(
        navigatorKey: navigatorKey,
        navigatorObservers: [routeObserver],
        title: AppLocalizations.appTitle,
        theme: theme,
        onGenerateRoute: (RouteSettings settings) => _getRoute(settings),
      ),
    );
  }
}

We use navigationKey to be able to navigate without the need of context (which will be handy in NavigationMiddleware) like this:

navigatorKey.currentState.pushNamed(routeName)

Next thing is routeObserver which observe Flutter Navigator events. It will help us with some default navigation events, but we will come back to it.

I know it’s a lot to proceed, but as a starting point, a lot of things initialize here that we will describe in details later on. Let's focus on the Redux part and start with the state of an application.

AppState

State of our application represents what we see on our screens. So logical is to put here information about our position in the app navigation stack.


@immutable
class AppState {
  final bool isLoading;
  final List<Game> games;
  final List<String> route;
  ...
}

I’ve represented that by a list of Strings named route. It’s like a back stack, so the last element on the list has a name of our current screen and the previous one represent the parent screen name. For example:

route[0] = “/”, route[1] = “/addGame”

Now it’s time to see how we will change that route property so let’s move to reducers.

Reducers

Our main reducer is pretty straight forward, each field has it’s own reducer.

AppState appReducer(AppState state, action) {
  return AppState(
    isLoading: loadingReducer(state.isLoading, action),
    games: gamesReducer(state.games, action),
    route: navigationReducer(state.route, action)
  );
}

NavigationReducer is the part that we are interested in.

final navigationReducer = combineReducers<List<String>>([
  TypedReducer<List<String>, NavigateReplaceAction>(_navigateReplace),
  TypedReducer<List<String>, NavigatePushAction>(_navigatePush),
  TypedReducer<List<String>, NavigatePopAction>(_navigatePop),
]);

List<String> _navigateReplace(
    List<String> route, NavigateReplaceAction action) =>
    [action.routeName];

List<String> _navigatePush(List<String> route, NavigatePushAction action) {
  var result = List<String>.from(route);
  result.add(action.routeName);
  return result;
}

List<String> _navigatePop(List<String> route, NavigatePopAction action) {
  var result = List<String>.from(route);
  if (result.isNotEmpty) {
    result.removeLast();
  }
  return result;
}

We declare three actions: NavigationReplaceAction, Push and Pop. Replace and Push have a routeName property that accordingly replaces or adds a route to our route list in AppState. Pop just remove the last item from the list.

So now we can add an onPressed listener to our navigations button which will dispatch one of those actions. For example:

floatingActionButton: FloatingActionButton(
        onPressed: () => StoreProvider.of<AppState>(context)
            .dispatch(NavigatePushAction(AppRoutes.addGame)),
        tooltip: 'Add new game',
        child: Icon(Icons.add),
      ),

However, that will only change the route list in the AppState. It won’t navigate to proper screen. That's why we added NavigationMiddleware to our store.

Here we listen to specific actions that are dispatched on the store and launch side effects like navigation changes.

List<Middleware<AppState>> createNavigationMiddleware() {
  return [
    TypedMiddleware<AppState, NavigateReplaceAction>(_navigateReplace),
    TypedMiddleware<AppState, NavigatePushAction>(_navigate),
  ];
}

_navigateReplace(Store<AppState> store, action, NextDispatcher next) {
  final routeName = (action as NavigateReplaceAction).routeName;
  if (store.state.route.last != routeName) {
    navigatorKey.currentState.pushReplacementNamed(routeName);
  }
  next(action); //This need to be after name checks
}

_navigate(Store<AppState> store, action, NextDispatcher next) {
  final routeName = (action as NavigatePushAction).routeName;
  if (store.state.route.last != routeName) {
    navigatorKey.currentState.pushNamed(routeName);
  }
  next(action); //This need to be after name checks
}

Here we use that navigationKey we declared in the main Widget. We needed to do it as here we don’t have access to the context. I’ve added checks to prevent calling navigation to the same place (which for example could launch animation again). Of course, that is only my implementation and yours could have different checks or none.

As you could notice we don't have here NavigatePopAction. That’s because we want to use out of the box navigation parts Fluttergive us. For example, when we call to push NewGame screen to navigation with AppBar Widget in it, Flutter will automatically add there back arrow to navigate to parent screen.

We don’t want to override everywhere those clicks and dispatch our actions, as it’s unnecessary work. However, we still want to track our route in AppState. That's why we added routeObserver in our main Widget. To track if Flutter Navigation was “Poped”.

RouteObserver

Below is routeObserver with RouteAwareWidget which is needed to track route changes. The key part here is RouteAware interface that is for objects that are aware of their current Route.

final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();

class RouteAwareWidget extends StatefulWidget {
  final Widget child;

  RouteAwareWidget({this.child});

  State<RouteAwareWidget> createState() => RouteAwareWidgetState(child: child);
}

class RouteAwareWidgetState extends State<RouteAwareWidget> with RouteAware {
  final Widget child;

  RouteAwareWidgetState({this.child});

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPush() {
    // Route was pushed onto navigator and is now topmost route.
  }

  @override
  void didPopNext() {
    // Covering route was popped off the navigator.
    StoreProvider.of<AppState>(context).dispatch(NavigatePopAction());
  }

  @override
  Widget build(BuildContext context) => Container(child: child);
}

Thanks to that we can now dispatch Pop action whenever we observe it in this Widget. Of course, we need to add it as root to each of our screens, but we can do it on the navigation level, like in the example below.

class FabRoute<T> extends MaterialPageRoute<T> {
  FabRoute(Widget widget, {RouteSettings settings})
      : super(
            builder: (_) => RouteAwareWidget(child: widget),
            settings: settings);

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
    if (settings.isInitialRoute) return child;
    return SlideTransition(
        position: new Tween<Offset>(
          begin: const Offset(0.0, 1.0),
          end: Offset.zero,
        ).animate(animation),
        child: child);
  }
}

So that’s all

If something was unclear or too complicated I made a public repository with my example that I hope will help you. It’s under development so some other things could be “in progress”.

Next, you could add analytics or crash reporting to middleware that would send the state of your app (with route inside) for better understanding bugs or users experience.

P.S. This article shows only my solution, but if you know how to improve it let me know :)

Photo by Andrew Neel on Unsplash

Photo of Marcin Oziemski

More posts by this author

Marcin Oziemski

Engineering Lead at Netguru
Efficient software development  Build faster, deliver more  Start now!

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business