flutterflutter-blocflutter-go-router

Correct approach to architect an app using a Scaffold with persistent BottomNavigationBar with Drawers/AppBar per Page with go_router and flutter_bloc


According to Flutter's documentation a Scaffold should not be nested.

The Scaffold seems to be an important class to get a basic visual structure that defines a lot of things, like the AppBar, BottomNavigationBar, and Drawers.

Not to nest makes sense. E.g., if you have a nested Scaffold with a Drawer, then the Drawer will not overlay the BottomNavigationBar from the outer Scaffold. See an example in this issue.

So, what I try to achieve is the following:

Idea 1: Have one Scaffold to rule them all

In terms of hierarchy, the app currently looks like this (stripped down to the essential parts):

- MaterialApp
  - ScaffoldBloc (BlocProvider & BlocBuilder)
    - Scaffold
      - IndexedStack
        - ShellPage
          - WidgetA
        - ShellPage
          - WidgetB
        - ShellPage
         - WidgetC

What I'm doing right now is the following:

Each ShellPage (my own Stateful widget) knows about four things: the actual Widget, as well as AppBar, Drawer and end Drawer.

When its state gets created, I send a message to the ScaffoldBloc which contains the AppBar and the Drawers.

Due to that, a BlocBuilder for the ScaffoldBloc triggers a build. That build will than use the current state's AppBar and Drawers for the Scaffold.

Basically, this approach works. However, it has the drawback, that you can see the content of WidgetA being rendered and due to the async nature of bloc "a frame later" you see the AppBar pop-in. Looks kind of ugly. See an example in this video (Dropbox, because I need MP4, in a GIF the pop-in was not visible)

Also the overall navigation handling does not seem to work well when you have nested routes that also have their own AppBar and Drawers.

Idea 2: Have one Scaffold per route

I've also tried changing the structure in this way:

- MaterialApp
  - IndexedStack
    - WidgetA
      - Scaffold
        - ActualWidgetAView
    - WidgetB
      - Scaffold
        - ActualWidgetBView
    - WidgetC
      - Scaffold
        - ActualWidgetCView

I like this approach more because it's easier to implement. However, each Scaffold now has its own BottomNavigationBar. When you switch from WidgetA to WidgetB you see the animation starting on WidgetA's BottomNavigationBar, then WidgetB gets rendered and you don't see the animation finishing because now WidgetB "overlays" the whole screen.

Animation glitch

Normally it should look like this:

Correct animation

Using a GlobalKey for the BottomNavigationBar does not work, because it will be rendered in multiple places then, for each Widget inside the IndexedStack.

Code/Configuration

Router configuration for both ideas

I stripped it down as much as possible, removed all imports and parts for brevity:

// Main routes.dart file

final rootNavigatorKey = GlobalKey<NavigatorState>();
final unauthenticatedShellNavigatorKey = GlobalKey<NavigatorState>();
final authenticatedShellNavigatorKey = GlobalKey<NavigatorState>();

const _statefulShellRoute = TypedStatefulShellRoute<AppShellRouteData>(
  branches: [
    $leaveBranch,
    $meBranch,
  ],
);

@TypedShellRoute<AppUnauthenticatedShellRouteData>(
  routes: [
    // Here are other routes, which are outside the stateful shell route, for example for signing in
    // ...
    _statefulShellRoute,
  ],
)
@immutable
class AppUnauthenticatedShellRouteData extends ShellRouteData {
  const AppUnauthenticatedShellRouteData();

  static final $navigatorKey = unauthenticatedShellNavigatorKey;

  @override
  Widget builder(final BuildContext context, final GoRouterState state, final Widget navigator) =>
      navigator;
}

@immutable
class AppShellRouteData extends StatefulShellRouteData {
  const AppShellRouteData();

  @override
  Widget builder(final BuildContext context,
      final GoRouterState state,
      final StatefulNavigationShell navigationShell) => navigationShell;

  static Widget $navigatorContainerBuilder(final BuildContext context,
      final StatefulNavigationShell navigationShell,
      final List<Widget> children,) =>
      ShellNavigator(
        navigationShell: navigationShell,
        navigationItems: [
          ShellNavigationItem(
            label: 'Leave',
            icon: FontAwesomeIcons.lightClock,
          ),
          ShellNavigationItem(
            label: 'Me',
            icon: FontAwesomeIcons.lightUser,
          ),
        ],
        children: children,
      );
}

final mainRouter = GoRouter(
  initialLocation: LeaveDashboardRoute().location,
  routes: $appRoutes,
  navigatorKey: rootNavigatorKey,
);

// Branch configuration for "leave"

const _leaveUrlPart = '/leave';

const $leaveBranch = TypedStatefulShellBranch(
  routes: [
    TypedGoRoute<LeaveDashboardRoute>(path: '$_leaveUrlPart/dashboard', routes: [
      TypedGoRoute<NestedTestRoute>(
        path: 'nested-test',
      ),
    ]),
  ],
);

@immutable
class LeaveDashboardRoute extends GoRouteData {
  @override
  Widget build(final BuildContext context, final GoRouterState state) => const Dashboard();
}

@immutable
class NestedTestRoute extends GoRouteData {
  @override
  Widget build(final BuildContext context, final GoRouterState state) => const Placeholder();
}

// Branch configuration for "me"

const _meUrlPart = '/me';

const $meBranch = TypedStatefulShellBranch(
  routes: [
    TypedGoRoute<MeDashboardRoute>(
      path: '$_meUrlPart/dashboard',
    ),
  ],
);

@immutable
class MeDashboardRoute extends GoRouteData {
  @override
  Widget build(final BuildContext context, final GoRouterState state) => const Me();
}

BLoC for Idea 1

// BLoC
class ShellBloc extends Bloc<ShellEvent, ShellState> {
  final StatefulNavigationShell navigationShell;

  ShellBloc({
    required this.navigationShell,
  }) : super(
          ShellState(),
        ) {
    on<ShellUpdate>(_updateAppBar);
    on<ShellNavigateToBranch>(_navigateToBranch);
  }

  FutureOr<void> _updateAppBar(final ShellUpdate event, final Emitter<ShellState> emit) {
    emit(
      state.copyWithComponents(
        navigationIndex: event.navigationIndex,
        endDrawer: event.endDrawer,
        drawer: event.drawer,
        appBar: event.appBar,
      ),
    );
  }

  FutureOr<void> _navigateToBranch(
    final ShellNavigateToBranch event,
    final Emitter<ShellState> emit,
  ) {
    emit(state.copyWith(navigationIndex: event.index));

    navigationShell.goBranch(
      event.index,
      initialLocation: event.index == navigationShell.currentIndex,
    );
  }
}

// State
class ShellComponents {
  final Drawer? drawer;
  final Drawer? endDrawer;
  final AppBar? appBar;

  const ShellComponents({
    this.drawer,
    this.endDrawer,
    this.appBar,
  });
}

@immutable
class ShellState extends Equatable {
  final Map<int, ShellComponents> shellComponents;
  final int navigationIndex;

  ShellState()
      : shellComponents = {},
        navigationIndex = 0;

  const ShellState._({
    required this.navigationIndex,
    required this.shellComponents,
  });

  AppBar? get appBar => shellComponents[navigationIndex]?.appBar;

  Drawer? get drawer => shellComponents[navigationIndex]?.drawer;

  Drawer? get endDrawer => shellComponents[navigationIndex]?.endDrawer;

  @override
  List<Object?> get props => [navigationIndex, shellComponents];

  ShellState copyWithComponents({
    required final int navigationIndex,
    final AppBar? appBar,
    final Drawer? drawer,
    final Drawer? endDrawer,
  }) {
    final shellComponents = Map<int, ShellComponents>.from(this.shellComponents);

    final components = ShellComponents(
      appBar: appBar,
      drawer: drawer,
      endDrawer: endDrawer,
    );
    shellComponents.update(
      navigationIndex,
      (final value) => components,
      ifAbsent: () => components,
    );

    final state = ShellState._(
      navigationIndex: navigationIndex,
      shellComponents: Map.from(shellComponents),
    );

    return state;
  }

  ShellState copyWith({
    required final int navigationIndex,
  }) {
    final shellComponents = Map<int, ShellComponents>.from(this.shellComponents);

    final state = ShellState._(
      navigationIndex: navigationIndex,
      shellComponents: Map.from(shellComponents),
    );

    return state;
  }
}

// Events
@immutable
abstract class ShellEvent {
  const ShellEvent();
}

class ShellUpdate extends ShellEvent {
  final Drawer? drawer;
  final Drawer? endDrawer;
  final AppBar? appBar;
  final int navigationIndex;

  const ShellUpdate({
    required this.navigationIndex,
    this.appBar,
    this.drawer,
    this.endDrawer,
  });
}

class ShellNavigateToBranch extends ShellEvent {
  final int index;

  const ShellNavigateToBranch({required this.index});
}

ShellNavigator for Idea 1

class ShellNavigator extends StatelessWidget {
  final StatefulNavigationShell navigationShell;
  final List<Widget> children;
  final List<ShellNavigationItem> navigationItems;

  const ShellNavigator({
    super.key,
    required this.navigationShell,
    required this.children,
    required this.navigationItems,
  });

  @override
  Widget build(final BuildContext context) => BlocProvider(
        create: (final context) => ShellBloc(
          navigationShell: navigationShell,
        ),
        child: _ShellNavigator(
          navigationItems: navigationItems,
          navigationShell: navigationShell,
          children: children,
        ),
      );
}

class _ShellNavigator extends StatelessWidget {
  final StatefulNavigationShell navigationShell;
  final List<Widget> children;
  final List<ShellNavigationItem> navigationItems;

  const _ShellNavigator({
    required this.navigationShell,
    required this.children,
    required this.navigationItems,
  });

  @override
  Widget build(final BuildContext context) => BlocBuilder<ShellBloc, ShellState>(
        builder: (final context, final state) => Scaffold(
          appBar: state.appBar,
          bottomNavigationBar: ShellBottomNavigationBar(
            navigationShell: navigationShell,
            items: navigationItems,
          ),
          drawer: state.drawer,
          endDrawer: state.endDrawer,
          body: IndexedStack(
            index: navigationShell.currentIndex,
            children: children
                .mapIndexed(
                  (final index, final element) => RepositoryProvider(
                    create: (final context) => NavigationIndexCubit(index: index),
                    child: element,
                  ),
                )
                .toList(growable: false),
          ),
          // body: child,
        ),
      );
}

Thoughts

Personally, I think Idea 2 is better, because it requires much less code. The only issue is the animation for the BottomNavigatioBar. If that somehow is solvable, that would be perfect. As written above, using a GlobalKey for BottomNavigationBar does not work, because it is inserted in multiple children of the IndexedStack. If it somehow would be possible to move the BottomNavigationBar from one Widget to another when the user presses the BottomNavigationBar, then the animation issue would be solved.

Other non-working/not-suitable solutions found online

For me, this UseCase seems pretty standard for a mobile app and I'm not sure, if my approach is completely wrong or if I'm doing something else wrong.

I also found an issue in Flutter's GitHub repository, asking basically the same thing.


Solution

  • Seems like a common use case. But, There's no easy way to do it that I know of. The way the navigator in Flutter works is, when pages are pushed they're stacked on top of each other. So, If you want persistent Appbars and BottomNavs, It would make sense to have a parent scaffold and switch out the body. But then comes the issue of how to update the Appbar for each body?

    The way I do it with go_router & flutter_bloc, I have a shell page with my top, bottoms and a bloc logic to handle adding and removing widgets.

    Example:

    I've put the full code in a single file so it's easy to copy and try. LMK if it's what you're looking for. So, I can explain further and beautify the answer.

    import 'package:flutter/material.dart';
    import 'package:flutter_bloc/flutter_bloc.dart';
    import 'package:go_router/go_router.dart';
    
    // goRouter start 
    final GlobalKey<NavigatorState> _rootNavKey =
        GlobalKey<NavigatorState>(debugLabel: 'root');
    
    final GlobalKey<NavigatorState> shellNavKey =
        GlobalKey<NavigatorState>(debugLabel: 'home shell');
    
    
    final GoRouter goRouter = GoRouter(
      navigatorKey: _rootNavKey,
      initialLocation: '/page1',
      routes: [
        ShellRoute(
          navigatorKey: shellNavKey,
          pageBuilder: (context, state, child) {
            return MaterialPage(
              key: state.pageKey,
              child: BlocProvider(
                create: (context) => ScaffCubit(),
                child: HomeShellPage(
                  body: child,
                ),
              ),
            );
          },
          routes: [
            GoRoute(
              name: 'page1',
              path: '/page1',
              parentNavigatorKey: shellNavKey,
              pageBuilder: (context, state) {
                return const NoTransitionPage(
                  child: Page1(),
                );
              },
            ),
            GoRoute(
              name: 'page2',
              path: '/page2',
              parentNavigatorKey: shellNavKey,
              pageBuilder: (context, state) {
                return const NoTransitionPage(
                  child: Page2(),
                );
              },
            ),
            GoRoute(
              name: 'page3',
              path: '/page3',
              parentNavigatorKey: shellNavKey,
              pageBuilder: (context, state) {
                return const NoTransitionPage(
                  child: Page3(),
                );
              },
            ),
          ],
        ),
      ],
    );
    /// goRouter end
    
    
    // bloc start
    @immutable
    sealed class ScaffState {}
    
    final class ScaffLoaded extends ScaffState {
      final List<PreferredSizeWidget> appBar;
      final List<Widget> drawer;
      final List<Widget> bottomNavBar;
    
      ScaffLoaded({
        required this.appBar,
        required this.drawer,
        required this.bottomNavBar,
      });
    }
    
    class ScaffCubit extends Cubit<ScaffState> {
      ScaffCubit()
          : super(ScaffLoaded(
              // Initiate the lists as empty avoiding null checks later
              appBar: List.empty(growable: true),
              drawer: List.empty(growable: true),
              bottomNavBar: List.empty(growable: true),
            ));
    
      add({
        PreferredSizeWidget? appBar,
        Widget? drawer,
        Widget? bottomNavBar,
      }) {
        ScaffLoaded s = state as ScaffLoaded;
        if (appBar != null) s.appBar.add(appBar);
        if (drawer != null) s.drawer.add(drawer);
        if (bottomNavBar != null) s.bottomNavBar.add(bottomNavBar);
    
        emit(ScaffLoaded(
          appBar: s.appBar,
          drawer: s.drawer,
          bottomNavBar: s.bottomNavBar,
        ));
      }
    
      removeLast({
        bool appBar = false,
        bool drawer = false,
        bool bottomNavBar = false,
      }) {
        ScaffLoaded s = state as ScaffLoaded;
        if (appBar && s.appBar.isNotEmpty) s.appBar.removeLast();
        if (drawer && s.drawer.isNotEmpty) s.drawer.removeLast();
        if (bottomNavBar && s.bottomNavBar.isNotEmpty) s.bottomNavBar.removeLast();
    
        emit(ScaffLoaded(
          appBar: s.appBar,
          drawer: s.drawer,
          bottomNavBar: s.bottomNavBar,
        ));
      }
    
      clear() {
        ScaffLoaded s = state as ScaffLoaded;
        emit(ScaffLoaded(
          appBar: s.appBar,
          drawer: s.drawer,
          bottomNavBar: s.bottomNavBar,
        ));
      }
    }
    /// bloc end
    
    
    // main start
    void main() {
      runApp(const MyApp());
    }
    /// main end
    
    // app start
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          routerConfig: goRouter,
        );
      }
    }
    // app end
    
    
    // pages start
    class HomeShellPage extends StatelessWidget {
      final Widget body;
    
      const HomeShellPage({super.key, required this.body});
    
      @override
      Widget build(BuildContext context) {
        return BlocBuilder<ScaffCubit, ScaffState>(
          builder: (context, state) {
            state as ScaffLoaded;
            return Scaffold(
              appBar: state.appBar.isEmpty
                  ? AppBar(
                      title: const Text('Home Shell Page'),
                    )
                  : state.appBar.last,
              body: body,
              drawer: state.drawer.isEmpty
                  ? Drawer(
                      child: ListView(
                        children: List.generate(4, (index) {
                          return ListTile(
                            title: Text('Button $index'),
                            subtitle: const Text('Home shell drawer'),
                          );
                        }),
                      ),
                    )
                  : null,
              bottomNavigationBar: BottomNavigationBar(
                items: const [
                  BottomNavigationBarItem(
                    icon: Icon(Icons.square_outlined),
                    label: 'Page 1',
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.circle_outlined),
                    label: 'Page 2',
                  ),
                ],
                onTap: (value) {
                  value == 0
                      ? context.goNamed(
                          'page1',
                        )
                      : context.goNamed('page2');
                },
              ),
            );
          },
        );
      }
    }
    
    class Page1 extends StatefulWidget {
      const Page1({super.key});
    
      @override
      State<Page1> createState() => _Page1State();
    }
    
    class _Page1State extends State<Page1> {
      @override
      Widget build(BuildContext context) {
        return const Center(
          child: Text('Page 1'),
        );
      }
    }
    
    class Page2 extends StatefulWidget {
      const Page2({super.key});
    
      @override
      State<Page2> createState() => _Page2State();
    }
    
    class _Page2State extends State<Page2> {
      late AppBar appBar;
      @override
      void initState() {
        appBar = AppBar(title: const Text('Page 2'));
        context.read<ScaffCubit>().add(appBar: appBar, drawer: null);
        super.initState();
      }
    
      @override
      void dispose() {
        shellNavKey.currentContext!.read<ScaffCubit>().removeLast(appBar: true);
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Page 2'),
            ElevatedButton(
              onPressed: () => context.pushNamed('page3'),
              child: const Text('Go to Page3'),
            )
          ],
        );
      }
    }
    
    class Page3 extends StatefulWidget {
      const Page3({super.key});
    
      @override
      State<Page3> createState() => _Page3State();
    }
    
    class _Page3State extends State<Page3> {
      late AppBar appBar;
      @override
      void initState() {
        appBar = AppBar(
          leading: BackButton(onPressed: () => context.pop()),
          title: const Text('Page 3'),
        );
        context.read<ScaffCubit>().add(appBar: appBar);
        super.initState();
      }
    
      @override
      void dispose() {
        shellNavKey.currentContext!.read<ScaffCubit>().removeLast(appBar: true);
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Page 3'),
            ElevatedButton(
              onPressed: () => shellNavKey.currentContext!.pushNamed('page4'),
              child: const Text('Go to page 4'),
            )
          ],
        );
      }
    }
    /// pages end