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.
Normally it should look like this:
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 import
s and part
s 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.
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.