Nesting routes with flutter

31,374

Solution 1

While it's technically possible to nest "Navigator", this is unrecommended here (as it breaks Hero animation)

You can use onGenerateRoute to build nested 'routes', in the case of a route '/dashboard/profile', build a Tree WidgetApp > Dashboard > Profile. Which I assume is what you're trying to achieve.

Combined with a higher order function, you can have something that creates onGenerateRoute for you.

To provide a clue of the code-flow: the NestedRoute neglects the exact build of the layout, letting it in the builder method (e.g. builder: (child) => new Dashboard(child: child),). When calling the buildRoute method we will generate a PageRouteBuilder for the very instance of this page, but letting _build manage the creation of the Widgets. In _build we either use the builder as is - or let it inflate the subroute, by recalling the requested subroute, calling its own _build. Once done, we'll be using the built subroute as the argument of our builder. Long story short, you recursively dive into further path levels to build the last level of the route, then let it rise from recursion and use the result as an argument for the outer level and so on.

BuildNestedRoutes does the dirty job for you and parses the lists of NestedRoutes to build the necessary RouteSettings.

So, from the below example

Example :

@override
Widget build(BuildContext context) {
  return new MaterialApp(
    initialRoute: '/foo/bar',
    home: const FooBar(),
    onGenerateRoute: buildNestedRoutes(
      [
        new NestedRoute(
          name: 'foo',
          builder: (child) => new Center(child: child),
          subRoutes: [
            new NestedRoute(
              name: 'bar',
              builder: (_) => const Text('bar'),
            ),
            new NestedRoute(
              name: 'baz',
              builder: (_) => const Text('baz'),
            )
          ],
        ),
      ],
    ),
  );
}

Here you simply defined your nested routes (name + associated component). And NestedRoute class + buildNestedRoutes method are defined this way :

typedef Widget NestedRouteBuilder(Widget child);

@immutable
class NestedRoute {
  final String name;
  final List<NestedRoute> subRoutes;
  final NestedRouteBuilder builder;

  const NestedRoute({@required this.name, this.subRoutes, @required this.builder});

  Route buildRoute(List<String> paths, int index) {
    return new PageRouteBuilder<dynamic>(
      pageBuilder: (_, __, ___) => _build(paths, index),
    );
  }

  Widget _build(List<String> paths, int index) {
    if (index > paths.length) {
      return builder(null);
    }
    final route = subRoutes?.firstWhere((route) => route.name == paths[index], orElse: () => null);
    return builder(route?._build(paths, index + 1));
  }
}

RouteFactory buildNestedRoutes(List<NestedRoute> routes) {
  return (RouteSettings settings) {
    final paths = settings.name.split('/');
    if (paths.length <= 1) {
      return null;
    }
    final rootRoute = routes.firstWhere((route) => route.name == paths[1]);
    return rootRoute.buildRoute(paths, 2);
  };
}

This way, your Foo and Bar components will not be tightly coupled with your routing system ; but still have nested routes. It's more readable then having your routes dispatched all over the place. And you'll easily add a new one.

Solution 2

You can use the standard Navigator as nested, without any additional tricks.

enter image description here

All what you need, it is to assign a global key and specify the necessary parameters. And of course, you need to care about android back button behaviour.

The only thing you need to know is that the context for this navigator will not be global. It will lead to some specific points in working with it.

The following example is a bit more complicated, but it allows you to see how you can set the nested routes from outside and inside for the navigator widget. In example we call setState in root page for set new route by initRoute of NestedNavigator.

  import 'package:flutter/material.dart';

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

  class App extends StatelessWidget {
    // This widget is the root of your application.
    @override
    Widget build(BuildContext context) {
      return MaterialApp(
        title: 'Nested Routing Demo',
        home: HomePage(),
      );
    }
  }

  class HomePage extends StatefulWidget {
    @override
    _HomeState createState() => _HomeState();
  }

  class _HomeState extends State<HomePage> {
    final GlobalKey<NavigatorState> navigationKey = GlobalKey<NavigatorState>();

    @override
    Widget build(BuildContext context) {
      return Scaffold(
        appBar: AppBar(
          title: Text('Root App Bar'),
        ),
        body: Column(
          children: <Widget>[
            Container(
              height: 72,
              color: Colors.cyanAccent,
              padding: EdgeInsets.all(18),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  Text('Change Inner Route: '),
                  RaisedButton(
                    onPressed: () {
                      while (navigationKey.currentState.canPop())
                        navigationKey.currentState.pop();
                    },
                    child: Text('to Root'),
                  ),
                ],
              ),
            ),
            Expanded(
              child: NestedNavigator(
                navigationKey: navigationKey,
                initialRoute: '/',
                routes: {
                  // default rout as '/' is necessary!
                  '/': (context) => PageOne(),
                  '/two': (context) => PageTwo(),
                  '/three': (context) => PageThree(),
                },
              ),
            ),
          ],
        ),
      );
    }
  }

  class NestedNavigator extends StatelessWidget {
    final GlobalKey<NavigatorState> navigationKey;
    final String initialRoute;
    final Map<String, WidgetBuilder> routes;

    NestedNavigator({
      @required this.navigationKey,
      @required this.initialRoute,
      @required this.routes,
    });

    @override
    Widget build(BuildContext context) {
      return WillPopScope(
        child: Navigator(
          key: navigationKey,
          initialRoute: initialRoute,
          onGenerateRoute: (RouteSettings routeSettings) {
            WidgetBuilder builder = routes[routeSettings.name];
            if (routeSettings.isInitialRoute) {
              return PageRouteBuilder(
                pageBuilder: (context, __, ___) => builder(context),
                settings: routeSettings,
              );
            } else {
              return MaterialPageRoute(
                builder: builder,
                settings: routeSettings,
              );
            }
          },
        ),
        onWillPop: () {
          if(navigationKey.currentState.canPop()) {
            navigationKey.currentState.pop();
            return Future<bool>.value(false);
          }
          return Future<bool>.value(true);
        },
      );
    }
  }

  class PageOne extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('Page One'),
              RaisedButton(
                onPressed: () {
                  Navigator.of(context).pushNamed('/two');
                },
                child: Text('to Page Two'),
              ),
            ],
          ),
        ),
      );
    }
  }

  class PageTwo extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('Page Two'),
              RaisedButton(
                onPressed: () {
                  Navigator.of(context).pushNamed('/three');
                },
                child: Text('go to next'),
              ),
              RaisedButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: Text('go to back'),
              ),
            ],
          ),
        ),
      );
    }
  }

  class PageThree extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('Page Three'),
              RaisedButton(
                onPressed: () {
                  Navigator.of(context).pop();
                },
                child: Text('go to back'),
              ),
            ],
          ),
        ),
      );
    }
  }

You can find some additional information in the next article.

Unfortunately, you cannot navigate to same root widget without navigation stack, when you change only child. So, for avoiding root widget navigation(root widget duplication) you need to create custom navigate method, for example based on InheritedWidget. In which you will check new root route and if it not changed to call only child(nested) navigator.

So you need to separate your route to two parts: '/onboarding' for root navigator and '/plan' for nested navigator and process this data separately.

Solution 3

The pattern you are trying to build, even if reasonable, seems it cannot be represented out of the box with Flutter.

EDIT: The behavior you want to achieve requires the use of onGenerateRoute, however not yet (Jan'18) properly documented (doc). See @Darky answer to have an example. He proposes NestedRouteBuilder and NestedRoute implementations, filling the gap.

Using plain Navigator from a MaterialApp, routes and pages navigation (according to doc) have two main characteristics that deny what you want to achieve (at least directly). On one hand, the Navigator behaves as a stack, thus pushing and popping routes one on top of the next and so on, on the other routes are either full screen or modal - meaning they occupy the screen partially, but they inhibit the interaction with the widgets beneath. Being more explicit, your paradigm seems to require the simultaneous interaction with pages at different levels in stack - which cannot be done this way.

Moreover, it feels like the path paradigm is not only a hierarchy - general frame → specific subpage - but in first instance a representation of the stack in navigator. I myself got tricked, but it becomes clear reading this:

String initialRoute

final

The name of the first route to show.

By default, this defers to dart:ui.Window.defaultRouteName.

If this string contains any / characters, then the string is split on those characters and substrings from the start of the string up to each such character are, in turn, used as routes to push.

For example, if the route /stocks/HOOLI was used as the initialRoute, then the Navigator would push the following routes on startup: /, /stocks, /stocks/HOOLI. This enables deep linking while allowing the application to maintain a predictable route history.

A possible workaround, as it follows, is to exploit the pathname to instantiate the child widgets, keeping a state variable to know what to show:

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new ActionPage(title: 'Flutter Demo Home Page'),
      routes: <String, WidgetBuilder>{
        '/action/plus': (BuildContext context) => new ActionPage(sub: 'plus'),
        '/action/minus': (BuildContext context) => new ActionPage(sub: 'minus'),
      },
    );
  }
}

class ActionPage extends StatefulWidget {
  ActionPage({Key key, this.title, this.sub = 'plus'}) : super(key: key);

  final String title, sub;

  int counter;

  final Map<String, dynamic> subroutes = {
    'plus': (BuildContext context, int count, dynamic setCount) =>
        new PlusSubPage(count, setCount),
    'minus': (BuildContext context, int count, dynamic setCount) =>
        new MinusSubPage(count, setCount),
  };

  @override
  ActionPageState createState() => new ActionPageState();
}

class ActionPageState extends State<ActionPage> {
  int _main_counter = 0;

  String subPageState;

  @override
  void initState() {
    super.initState();
    subPageState = widget.sub;
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text('Testing subpages'),
          actions: <Widget>[
            new FlatButton(
                child: new Text('+1'),
                onPressed: () {
                  if (subPageState != 'plus') {
                    setState(() => subPageState = 'plus');
                    setState(() => null);
                  }
                }),
            new FlatButton(
                child: new Text('-1'),
                onPressed: () {
                  if (subPageState != 'minus') {
                    setState(() => subPageState = 'minus');
                    setState(() => null);
                  }
                }),
          ],
        ),
        body: widget.subroutes[subPageState](context, _main_counter, (count) {
          _main_counter = count;
        }));
  }
}

class PlusSubPage extends StatefulWidget {
  PlusSubPage(this.counter, this.setCount);
  final setCount;
  final int counter;
  @override
  _PlusSubPageState createState() => new _PlusSubPageState();
}

class _PlusSubPageState extends State<PlusSubPage> {
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _counter = widget.counter;
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
      widget.setCount(_counter);
    });
  }

  @override
  Widget build(BuildContext context) {
    return new Center(
      child: new Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          new IconButton(
            icon: const Icon(Icons.add),
            onPressed: _incrementCounter,
          ),
          new Text(
            'You have pushed the button this many times:',
          ),
          new Text(
            '$_counter',
            style: Theme.of(context).textTheme.display1,
          ),
        ],
      ),
    );
  }
}

class MinusSubPage extends StatefulWidget {
  MinusSubPage(this.counter, this.setCount);
  final setCount;
  final int counter;
  @override
  _MinusSubPageState createState() => new _MinusSubPageState();
}

class _MinusSubPageState extends State<MinusSubPage> {
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _counter = widget.counter;
  }

  void _decrementCounter() {
    setState(() {
      _counter--;
      widget.setCount(_counter);
    });
  }

  @override
  Widget build(BuildContext context) {
    return new Center(
      child: new Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          new IconButton(
            icon: const Icon(Icons.remove),
            onPressed: _decrementCounter,
          ),
          new Text(
            'You have pushed the button this many times:',
          ),
          new Text(
            '$_counter',
            style: Theme.of(context).textTheme.display1,
          ),
        ],
      ),
    );
  }
}

This, however, has no stack memory at lower level. In case you want to handle the sequence of subroutes widget - you can wrap the subroute container in a WillPopScope, defining there what it is supposed to be doing when user presses back button, and storing the sequence of the subroutes in a stack. However I don't feel like suggesting such a thing.

My final suggestion is to implement plain routes - without "levels" -, manage custom transitions to hide the change of "outer" layout and pass the data through pages or keep in a proper class providing you app state.

PS: check also Hero animations, they can provide you the continuity you look for between views.

Share:
31,374
Ilja
Author by

Ilja

That dev at the local ☕️ shop sipping on late.

Updated on April 03, 2020

Comments

  • Ilja
    Ilja about 4 years

    I am trying to figure out good architectural solution for following problem: I have following First level routes that can also be referred to as layouts:

    /onboarding/* -> Shows onboarding layout
    /dashboard/* -> Shows dashboard layout
    /overlay/* -> shows slide up overlay layout
    /modal/* -> shows modal layout
    

    User is routed to each of these depending on his/her auth state, actions etc.. I got this stage correctly.

    Issues arise when I want to use Secondary level routes that can be referred to as pages, for example

    /onboarding/signin -> Shows onboarding layout, that displays signin route
    /onboarding/plan -> Shows onboarding layout, that displays plan options
    /modal/plan-info -> Shows modal layout, over previous page (/onboarding/plan) and displays plan-information page.
    

    How can I best define / organise these in a way where I can efficiently route to layouts and pages they display? Note, that whenever I route pages inside one layout, layout is not changing, but I want to animate content (pages) that are changing inside of it based on route.

    Thus far I achieved following

    import "package:flutter/widgets.dart";
    import "package:skimitar/layouts/Onboarding.dart";
    import "package:skimitar/layouts/Dashboard.dart";
    
    Route generate(RouteSettings settings) {
      Route page;
      switch (settings.name) {
        case "/onboarding":
          page = new PageRouteBuilder(pageBuilder: (BuildContext context,
              Animation<double> animation, Animation<double> secondaryAnimation) {
            return new Onboarding();
          });
          break;
          case "/dashboard":
          page = new PageRouteBuilder(pageBuilder: (BuildContext context,
              Animation<double> animation, Animation<double> secondaryAnimation) {
            return new Dashboard();
          });
          break;
      }
      return page;
    }
    
    /* Main */
    void main() {
      runApp(new WidgetsApp(
          onGenerateRoute: generate, color: const Color(0xFFFFFFFFF)));
    }
    

    This routes to on boarding and dashboard layouts (right now just simple Containers wrapping text). I also believe that I can use PageRouteBuilder latter on to animate transitions between routes? Now I need to figure out how to have something like nested secondary router inside on boarding and dashboard.

    Below is somewhat of a visual representation of what I want to achieve, I need to be able to successfully route blue and red bits. In this example as long as we are under /dashboard blue bit (layout) doesn't change, but as we navigate from say /dashboard/home to /dashboard/stats the red bit (page) should fade out and fade in with new content. If we navigate away from /dashboard/home to say /onboarding/home, the red bit (layout) should fade away, along with its currently active page and show new layout for onboarding and the story continues.

    enter image description here

    EDIT I made a bit of the progress with approach outlined below, essentially I will determine layout inside my runApp and will declare new WidgetsApp and routes inside each of the layouts. It seems to work, but there is an issue, When I click "SignUp" I am redirected to correct page, but I can also see old page below it.

    main.dart

    import "package:flutter/widgets.dart";
    import "package:myProject/containers/layouts/Onboarding.dart";
    
    /* Main */
    void main() {
      runApp(new Onboarding());
    }
    

    Onboarding.dart

    import "package:flutter/widgets.dart";
    import "package:myProject/containers/pages/SignIn.dart";
    import "package:myProject/containers/pages/SignUp.dart";
    import "package:myProject/services/helpers.dart";
    
    /* Onboarding router */
    Route onboardingRouter(RouteSettings settings) {
      Route page;
      switch (settings.name) {
        case "/":
          page = buildOnboardingRoute(new SignIn());
          break;
        case "/sign-up":
          page = buildOnboardingRoute(new SignUp());
          break;
        default:
          page = buildOnboardingRoute(new SignIn());
      }
      return page;
    }
    
    class Onboarding extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return new Container(
          decoration: new BoxDecoration(
              color: const Color(0xFF000000),
              image: new DecorationImage(
                  image: new AssetImage("assets/images/background-fire.jpg"),
                  fit: BoxFit.cover)),
          child: new WidgetsApp(
              onGenerateRoute: onboardingRouter, color: const Color(0xFF000000)),
        );
      }
    }
    

    SignUp.dart

    import "package:flutter/widgets.dart";
    
    class SignUp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return new Center(
            child: new Text("Sign Up",
                style: new TextStyle(color: const Color(0xFFFFFFFF))));
      }
    }
    

    helpers.dart

    import "package:flutter/widgets.dart";
    
    Route buildOnboardingRoute(Widget page) {
      return new PageRouteBuilder(
          opaque: true,
          pageBuilder: (BuildContext context, _, __) {
            return page;
          });
    }
    
  • Rémi Rousselet
    Rémi Rousselet over 6 years
    This is wrong. You can easily have nested routes using onGenerateRoute. I'll submit an example in a few minutes
  • Fabio Veronese
    Fabio Veronese over 6 years
    @Darky I scouted the doc for days and found not a single clue except what I found. I myself thought it could be done - and should be possible to do it easily, but I didn't find an elegant way. Looking forward to seeing the example and hoping they'll soon improve Flutter doc about these issues too.
  • Fabio Veronese
    Fabio Veronese over 6 years
    I give you this is more elegant and compatible with stack pop-pull. Anyhow is this code still re-drawing also Dashboard whenever I want to navigate from profile (dashboard/profile) to foo (dashboard/foo)? Isn't this the same as having two different routes with Dashboard(child: profile) and Dashboard(child:foo)?
  • Rémi Rousselet
    Rémi Rousselet over 6 years
    Not exactly. Flatten all possible routes in the routes property of WidgetApp will lead to a lot of repetition. It will work ; but will be less maintainable then this approach. Especially on bigger routes.
  • Rémi Rousselet
    Rémi Rousselet over 6 years
    Another difference is that you'll be able to have dynamic routing like /dashboard/profile/{userId} with little to no modification. You can even plug a code generator to extract the constructor's paramaters with parsing and type checking.
  • Fabio Veronese
    Fabio Veronese over 6 years
    Thanks for the precious info. I only suggest you to improve a little the theory behind the explanation and add some references to official doc ;)
  • Rémi Rousselet
    Rémi Rousselet over 6 years
    I'm not very good with explanations (english is not my native language). Feel free to edit my post. Also for the Anyhow is this code still re-drawing also Dashboard whenever I want to navigate from profile (dashboard/profile) to foo (dashboard/foo)? That's an interesting question. The example I gave will. But you could potentially memoize widgets build + use repaintBoundaries to prevent this. (which is another advantage of this approach)
  • Ilja
    Ilja over 6 years
    Wow, this is good. But flutter docs do not make it clear I had no idea this can be done, thank you for your time
  • Andrew
    Andrew over 4 years
    @RémiRousselet I think you might have a typo in your code - did you mean to use the home property and the initialRoute property? It threw me off for a moment. For anyone else who's reading this: in your own code, you'll probably be opting out of the home-property and letting the initialRoute property do the building you want.
  • Rémi Rousselet
    Rémi Rousselet over 4 years
    @Andrew you need both. initialRoute push home in the history stack too, for the back button
  • Andrew
    Andrew over 4 years
    I just read the documentation a bit further - there seems to be a lot of details and caveats I didn't catch -- to many to follow up on. But I got the warning from one of the flutter navigation cookbook pages and realized why I got confused after checking the docs. Thanks @RémiRousselet!!
  • Jonas
    Jonas about 4 years
    Nice Solution! However I have noticed, on hot reloading, the state of the nested navigator gets lost and I will start at the initial route, wich makes developing very anoying. Has anyone had the same issue?