Flutter web Bottom Navigation Bar with url updating and hyperlink support

Issue

I have been searching around for ages online for an example of a flutter app that supports the following:

  1. Persistent bottom navigation bar
  2. Navigator support for each bottom navigation bar item that persists state
  3. Navigators update the URL in flutter web on navigation
  4. Navigators support hyperlinking to a specific page in flutter web. For example if a user types www.myappdomain.com/#/profile they get navigated to the profile page. Or they type www.myappdomain.com/#/profile?id=123 they get navigated to the profile page for user 123.

I’ve been able to achieve points 1 and 2 with the following code:

import 'package:flutter/material.dart';

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

class BaseApp extends StatefulWidget {
  @override
  _BaseAppState createState() => _BaseAppState();
}

class _BaseAppState extends State<BaseApp> {
  // define all navigation tabs for user
  static List<NavigationTabModel> _navigationTabs = [
    NavigationTabModel(
      title: 'Home',
      icon: Icons.home,
      url: '/home',
      navigatorKey: GlobalKey<NavigatorState>(),
    ),
    NavigationTabModel(
      title: 'Search',
      icon: Icons.search,
      url: '/search',
      navigatorKey: GlobalKey<NavigatorState>(),
    ),
    NavigationTabModel(
      title: 'Profile',
      icon: Icons.person,
      url: '/profile',
      navigatorKey: GlobalKey<NavigatorState>(),
    ),
  ];

  // route generator used to build all material page routes
  RouteGenerator _routeGenerator = RouteGenerator();

  // set the current tab to the home page
  int _currentIndex = 0;

  void _select(int index) {
    if (index == _currentIndex) {
      // case 1 - if user presses on currently selected tab
      // pop to first route - i.e. ensure no routes are over laid on top of the current route
      _navigationTabs[_currentIndex]
          .navigatorKey
          .currentState
          .popUntil((route) {
        return route.isFirst;
      });
    } else {
      // case 2 - user selects any other tab
      // rebuild application state with the newly selected navigation tab
      setState(() {
        _currentIndex = index;
      });
    }
  }

  /// generate a list of navigators that will have their state persisted in an
  /// indexed stack.
  List<Widget> _getPersistantStack() {
    return _navigationTabs.map((tab) {
      return WillPopScope(
        onWillPop: () async {
          return !await tab.navigatorKey.currentState.maybePop();
        },
        child: Navigator(
          key: tab.navigatorKey,
          initialRoute: tab.url,
          onGenerateRoute: _routeGenerator.generateRoute,
        ),
      );
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    /// ********************* HOLD POINT *********************
    /// MaterialApp contains our top-level Navigator. Top level navigator is
    /// required to enable navigation via urls in flutter web. Likely that this
    /// section requires refractoring in some way to enable url updates from the
    /// nested navigators and hyperlinking from web browsers to specific pages
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: SafeArea(
          child: IndexedStack(
            children: _getPersistantStack(),
            index: _currentIndex,
          ),
        ),
        bottomNavigationBar: BottomNavigationBar(
          items: _navigationTabs.map((tab) {
            return BottomNavigationBarItem(
              label: tab.title,
              icon: Icon(tab.icon),
            );
          }).toList(),
          onTap: (int index) {
            _select(index);
          },
          currentIndex: _currentIndex,
          type: BottomNavigationBarType.fixed,
          // hide titles on navigation bar
          showSelectedLabels: false,
          showUnselectedLabels: false,
        ),
      ),
    );
  }
}

class NavigationTabModel {
  final String title;
  final IconData icon;
  final String url;
  final GlobalKey<NavigatorState> navigatorKey;

  NavigationTabModel({
    this.title,
    this.icon,
    this.url,
    this.navigatorKey,
  });
}

class RouteGenerator {
  Route<dynamic> generateRoute(RouteSettings settings) {
    // Widget builder (function that returns a widget) to construct the route page
    WidgetBuilder builder;

    // build different route (page) based on the route passed to the navigator
    switch (settings.name) {
      case '/home':
        builder = (BuildContext context) {
          return SamplePage(name: 'home');
        };
        break;
      case '/search':
        builder = (BuildContext context) {
          return SamplePage(name: 'search');
        };
        break;
      case '/profile':
        builder = (BuildContext context) {
          return SamplePage(name: 'profile');
        };
        break;
      case '/':
        builder = null;
        break;
      default:
        // If there is no such named route in the switch statement
        builder = (BuildContext context) {
          return SamplePage();
        };
    }
    // prevent page being added to default '/' route
    if (builder == null) {
      return null;
    }
    return MaterialPageRoute(
      builder: builder,
      settings: settings,
    );
  }
}

class SamplePage extends StatelessWidget {
  final String name;

  SamplePage({
    this.name,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('$name'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            RaisedButton(
              child: Text('push new route'),
              onPressed: () {
                Navigator.of(context).pushNamed('/$name');
              },
            ),
            SizedBox(
              height: 16,
            ),
            Expanded(
              child: ListView.builder(
                itemCount: 100,
                itemBuilder: (context, index) {
                  return Card(
                    child: Text(
                      index.toString(),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

However I cannot figure out how to update this app to achieve points 3 and 4. Does anyone know how this can be achieved?

Solution

While Navigator 2.0 is a bit intimidating at first, it is well worth it when you are (mainly) targeting web apps, because you have a lot of options for restoring state from a deep link.

In the official introduction which @Lulupointu already linked to, there is an example with exactly what you are looking for (but a bit hidden at the very bottom of the article).

In this gist is a working example for using different Navigator Stacks for different tabs, which for completeness I post below. It works for me on Channel beta, 1.23.0-18.1.pre

import 'package:flutter/material.dart';

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

class Book {
  final String title;
  final String author;

  Book(this.title, this.author);
}

class NestedRouterDemo extends StatefulWidget {
  @override
  _NestedRouterDemoState createState() => _NestedRouterDemoState();
}

class _NestedRouterDemoState extends State<NestedRouterDemo> {
  BookRouterDelegate _routerDelegate = BookRouterDelegate();
  BookRouteInformationParser _routeInformationParser =
      BookRouteInformationParser();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Books App',
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeInformationParser,
    );
  }
}

class BooksAppState extends ChangeNotifier {
  int _selectedIndex;

  Book _selectedBook;

  final List<Book> books = [
    Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
    Book('Foundation', 'Isaac Asimov'),
    Book('Fahrenheit 451', 'Ray Bradbury'),
  ];

  BooksAppState() : _selectedIndex = 0;

  int get selectedIndex => _selectedIndex;

  set selectedIndex(int idx) {
    _selectedIndex = idx;
    notifyListeners();
  }

  Book get selectedBook => _selectedBook;

  set selectedBook(Book book) {
    _selectedBook = book;
    notifyListeners();
  }

  int getSelectedBookById() {
    if (!books.contains(_selectedBook)) return 0;
    return books.indexOf(_selectedBook);
  }

  void setSelectedBookById(int id) {
    if (id < 0 || id > books.length - 1) {
      return;
    }

    _selectedBook = books[id];
    notifyListeners();
  }
}

class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
  @override
  Future<BookRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.location);

    if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'settings') {
      return BooksSettingsPath();
    } else {
      if (uri.pathSegments.length >= 2) {
        if (uri.pathSegments[0] == 'book') {
          return BooksDetailsPath(int.tryParse(uri.pathSegments[1]));
        }
      }
      return BooksListPath();
    }
  }

  @override
  RouteInformation restoreRouteInformation(BookRoutePath configuration) {
    if (configuration is BooksListPath) {
      return RouteInformation(location: '/home');
    }
    if (configuration is BooksSettingsPath) {
      return RouteInformation(location: '/settings');
    }
    if (configuration is BooksDetailsPath) {
      return RouteInformation(location: '/book/${configuration.id}');
    }
    return null;
  }
}

class BookRouterDelegate extends RouterDelegate<BookRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
  final GlobalKey<NavigatorState> navigatorKey;

  BooksAppState appState = BooksAppState();

  BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>() {
    appState.addListener(notifyListeners);
  }

  @override
  BookRoutePath get currentConfiguration {
    if (appState.selectedIndex == 1) {
      return BooksSettingsPath();
    } else {
      if (appState.selectedBook == null) {
        return BooksListPath();
      } else {
        return BooksDetailsPath(appState.getSelectedBookById());
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        MaterialPage(
          child: AppShell(appState: appState),
        ),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }

        if (appState.selectedBook != null) {
          appState.selectedBook = null;
        }
        notifyListeners();
        return true;
      },
    );
  }

  @override
  Future<void> setNewRoutePath(BookRoutePath path) async {
    if (path is BooksListPath) {
      appState.selectedIndex = 0;
      appState.selectedBook = null;
    } else if (path is BooksSettingsPath) {
      appState.selectedIndex = 1;
    } else if (path is BooksDetailsPath) {
      appState.selectedIndex = 0;
      appState.setSelectedBookById(path.id);
    }
  }
}

// Routes
abstract class BookRoutePath {}

class BooksListPath extends BookRoutePath {}

class BooksSettingsPath extends BookRoutePath {}

class BooksDetailsPath extends BookRoutePath {
  final int id;

  BooksDetailsPath(this.id);
}

// Widget that contains the AdaptiveNavigationScaffold
class AppShell extends StatefulWidget {
  final BooksAppState appState;

  AppShell({
    @required this.appState,
  });

  @override
  _AppShellState createState() => _AppShellState();
}

class _AppShellState extends State<AppShell> {
  InnerRouterDelegate _routerDelegate;
  ChildBackButtonDispatcher _backButtonDispatcher;

  void initState() {
    super.initState();
    _routerDelegate = InnerRouterDelegate(widget.appState);
  }

  @override
  void didUpdateWidget(covariant AppShell oldWidget) {
    super.didUpdateWidget(oldWidget);
    _routerDelegate.appState = widget.appState;
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // Defer back button dispatching to the child router
    _backButtonDispatcher = Router.of(context)
        .backButtonDispatcher
        .createChildBackButtonDispatcher();
  }

  @override
  Widget build(BuildContext context) {
    var appState = widget.appState;

    // Claim priority, If there are parallel sub router, you will need
    // to pick which one should take priority;
    _backButtonDispatcher.takePriority();

    return Scaffold(
      appBar: AppBar(),
      body: Router(
        routerDelegate: _routerDelegate,
        backButtonDispatcher: _backButtonDispatcher,
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(
              icon: Icon(Icons.settings), label: 'Settings'),
        ],
        currentIndex: appState.selectedIndex,
        onTap: (newIndex) {
          appState.selectedIndex = newIndex;
        },
      ),
    );
  }
}

class InnerRouterDelegate extends RouterDelegate<BookRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  BooksAppState get appState => _appState;
  BooksAppState _appState;
  set appState(BooksAppState value) {
    if (value == _appState) {
      return;
    }
    _appState = value;
    notifyListeners();
  }

  InnerRouterDelegate(this._appState);

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        if (appState.selectedIndex == 0) ...[
          FadeAnimationPage(
            child: BooksListScreen(
              books: appState.books,
              onTapped: _handleBookTapped,
            ),
            key: ValueKey('BooksListPage'),
          ),
          if (appState.selectedBook != null)
            MaterialPage(
              key: ValueKey(appState.selectedBook),
              child: BookDetailsScreen(book: appState.selectedBook),
            ),
        ] else
          FadeAnimationPage(
            child: SettingsScreen(),
            key: ValueKey('SettingsPage'),
          ),
      ],
      onPopPage: (route, result) {
        appState.selectedBook = null;
        notifyListeners();
        return route.didPop(result);
      },
    );
  }

  @override
  Future<void> setNewRoutePath(BookRoutePath path) async {
    // This is not required for inner router delegate because it does not
    // parse route
    assert(false);
  }

  void _handleBookTapped(Book book) {
    appState.selectedBook = book;
    notifyListeners();
  }
}

class FadeAnimationPage extends Page {
  final Widget child;

  FadeAnimationPage({Key key, this.child}) : super(key: key);

  Route createRoute(BuildContext context) {
    return PageRouteBuilder(
      settings: this,
      pageBuilder: (context, animation, animation2) {
        var curveTween = CurveTween(curve: Curves.easeIn);
        return FadeTransition(
          opacity: animation.drive(curveTween),
          child: child,
        );
      },
    );
  }
}

// Screens
class BooksListScreen extends StatelessWidget {
  final List<Book> books;
  final ValueChanged<Book> onTapped;

  BooksListScreen({
    @required this.books,
    @required this.onTapped,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          for (var book in books)
            ListTile(
              title: Text(book.title),
              subtitle: Text(book.author),
              onTap: () => onTapped(book),
            )
        ],
      ),
    );
  }
}

class BookDetailsScreen extends StatelessWidget {
  final Book book;

  BookDetailsScreen({
    @required this.book,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            FlatButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: Text('Back'),
            ),
            if (book != null) ...[
              Text(book.title, style: Theme.of(context).textTheme.headline6),
              Text(book.author, style: Theme.of(context).textTheme.subtitle1),
            ],
          ],
        ),
      ),
    );
  }
}

class SettingsScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('Settings screen'),
      ),
    );
  }
}

If you want the Home Navigation Button to link to the homepage and not keep the state, then you have to replace set selectedIndex with this:

  set selectedIndex(int idx) {
    _selectedIndex = idx;
    if (_selectedIndex == 1) {
      // Remove this line if you want to keep the selected book when navigating
      // between "settings" and "home" which book was selected when Settings is
      // tapped.
      selectedBook = null;
    }
    notifyListeners();
  }

You might want to take a look at how to extract query parameters, when writing your own RouteInformationParser:
Parsing a URI to extract query parameters, with Dart

Here is another example you might want to take a look at for understanding Navigator 2.0: https://github.com/flutter/flutter/pull/63424

Answered By – Damian K. Bast

Answer Checked By – Mary Flores (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *