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:
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.
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.
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
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});
}
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,
),
);
}
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.
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.
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