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:

  • Have only one Scaffold.
  • Have an animated BottomNavigationBar
  • Have an IndexedStack as the Scaffold's body to allow multiple Navigators with go_router's StatefulShellBranches (actually using go_router_builder)
  • Have a (end) Drawer and AppBar, which will be different per route
  • Utilize flutter_bloc if it makes sense.

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

  • Nest Scaffolds, which does not work, as described above.
  • One Drawer/AppBar for the whole app - does not make sense in my case.
  • Somehow "misuse" a TabController and figure out the AppBar via it's tabChange-listener. I don't know if that is a usable thing regarding go_router and StatefulBranches. Also it would need some kind of "registry" where to find the AppBar for the current view.

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.

2

There are 2 answers

3
Cabdirashiid On BEST ANSWER

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
1
xenos92 On

Instead of nesting Scaffold widgets or having multiple Scaffold widgets, you can have a single Scaffold whose AppBar and Drawer are controlled by a Bloc.

This way, when you navigate to a different page or tab, you can send an event to the Bloc to update the AppBar and Drawer for that specific page.

Use IndexedStack for Persistent BottomNavigationBar: Continue using the IndexedStack for the body of the Scaffold to maintain the state of each page and to ensure that the BottomNavigationBar animation is consistent.

Here's how you can implement this:

1. Define the Bloc and State for the Scaffold:

// BLoC
class ScaffoldBloc extends Bloc<ScaffoldEvent, ScaffoldState> {
  ScaffoldBloc() : super(InitialScaffoldState());

  @override
  Stream<ScaffoldState> mapEventToState(ScaffoldEvent event) async* {
    if (event is UpdateAppBarAndDrawerEvent) {
      yield ScaffoldState(
        appBar: event.appBar,
        drawer: event.drawer,
        currentIndex: event.currentIndex,
      );
    }
  }
}

// State
class ScaffoldState {
  final AppBar appBar;
  final Drawer drawer;
  final int currentIndex;

  ScaffoldState({
    required this.appBar,
    required this.drawer,
    required this.currentIndex,
  });
}

// Events
abstract class ScaffoldEvent {}

class UpdateAppBarAndDrawerEvent extends ScaffoldEvent {
  final AppBar appBar;
  final Drawer drawer;
  final int currentIndex;

  UpdateAppBarAndDrawerEvent({
    required this.appBar,
    required this.drawer,
    required this.currentIndex,
  });
}

2. Main Widget with BlocBuilder:

class MainScaffold extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ScaffoldBloc, ScaffoldState>(
      builder: (context, state) {
        return Scaffold(
          appBar: AnimatedSwitcher(
            duration: Duration(milliseconds: 300),
            child: state.appBar,
          ),
          drawer: state.drawer,
          bottomNavigationBar: YourBottomNavigationBar(),
          body: IndexedStack(
            index: state.currentIndex,
            children: yourPageWidgets,
          ),
        );
      },
    );
  }
}

3. Update AppBar and Drawer on Navigation:

When you navigate to a new page or tab, you should send an UpdateAppBarAndDrawerEvent to the ScaffoldBloc to update the AppBar and Drawer for that page.

BlocProvider.of<ScaffoldBloc>(context).add(
  UpdateAppBarAndDrawerEvent(
    appBar: newAppBar,
    drawer: newDrawer,
    currentIndex: newIndex,
  ),
);

This approach ensures that you have a single Scaffold throughout your app, and you can dynamically update its AppBar and Drawer based on the current page or tab. It also ensures that the BottomNavigationBar remains consistent and doesn't restart its animation when switching between tabs.