Skip to content

Instantly share code, notes, and snippets.

@maheshmnj
Last active September 2, 2023 11:53
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save maheshmnj/894922ccb67f5fdc4ffb652e41916fa2 to your computer and use it in GitHub Desktop.
Save maheshmnj/894922ccb67f5fdc4ffb652e41916fa2 to your computer and use it in GitHub Desktop.
Demo app for a medium post "Everything about the Nested bottom navigation bar"
/*
* File: main.dart
* Project: BottomNavigationBar demo
* File Created: Wednesday, 26th May 2022 1:15:47 pm
* Author: Mahesh Jamdade
* -----
* Last Modified: Saturday, 28th May 2022 4:42:07 pm
* Modified By: Mahesh Jamdade
* -----
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BottomNavbar Demo',
theme: ThemeData(
primarySwatch: Colors.indigo,
),
routes: {
// This route needs to be registered, Because
// we are pushing this on the main Navigator Stack on line 754 (isRootNavigator:true)
ProfileEdit.route: (context) => const ProfileEdit(),
},
home: const NavBarHandler());
}
}
class MenuItem {
const MenuItem(this.iconData, this.text);
final IconData iconData;
final String text;
}
Future<void> navigate(BuildContext context, String route,
{bool isDialog = false,
bool isRootNavigator = true,
Map<String, dynamic>? arguments}) =>
Navigator.of(context, rootNavigator: isRootNavigator)
.pushNamed(route, arguments: arguments);
final homeKey = GlobalKey<NavigatorState>();
final productsKey = GlobalKey<NavigatorState>();
final profileKey = GlobalKey<NavigatorState>();
final NavbarNotifier _navbarNotifier = NavbarNotifier();
List<Color> colors = [mediumPurple, Colors.orange, Colors.teal];
const Color mediumPurple = Color.fromRGBO(79, 0, 241, 1.0);
const String placeHolderText =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
class NavBarHandler extends StatefulWidget {
const NavBarHandler({Key? key}) : super(key: key);
static const String route = '/';
@override
State<NavBarHandler> createState() => _NavBarHandlerState();
}
class _NavBarHandlerState extends State<NavBarHandler>
with SingleTickerProviderStateMixin {
final _buildBody = const <Widget>[HomeMenu(), ProductsMenu(), ProfileMenu()];
late List<BottomNavigationBarItem> _bottomList = <BottomNavigationBarItem>[];
final menuItemlist = const <MenuItem>[
MenuItem(Icons.home, 'Home'),
MenuItem(Icons.shopping_basket, 'Products'),
MenuItem(Icons.person, 'Me'),
];
late Animation<double> fadeAnimation;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
fadeAnimation = Tween<double>(begin: 0.4, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.fastOutSlowIn),
);
_bottomList = List.generate(
_buildBody.length,
(index) => BottomNavigationBarItem(
icon: Icon(menuItemlist[index].iconData),
label: menuItemlist[index].text,
)).toList();
_controller.forward();
}
void showSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
behavior: SnackBarBehavior.floating,
duration: Duration(milliseconds: 600),
margin: EdgeInsets.only(
bottom: kBottomNavigationBarHeight, right: 2, left: 2),
content: Text('Tap back button again to exit'),
),
);
}
void hideSnackBar() {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
DateTime oldTime = DateTime.now();
DateTime newTime = DateTime.now();
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final bool isExitingApp = await _navbarNotifier.onBackButtonPressed();
if (isExitingApp) {
newTime = DateTime.now();
int difference = newTime.difference(oldTime).inMilliseconds;
oldTime = newTime;
if (difference < 1000) {
hideSnackBar();
return isExitingApp;
} else {
showSnackBar();
return false;
}
} else {
return isExitingApp;
}
},
child: Material(
child: AnimatedBuilder(
animation: _navbarNotifier,
builder: (context, snapshot) {
return Stack(
children: [
IndexedStack(
index: _navbarNotifier.index,
children: [
for (int i = 0; i < _buildBody.length; i++)
FadeTransition(
opacity: fadeAnimation, child: _buildBody[i])
],
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: AnimatedNavBar(
model: _navbarNotifier,
onItemTapped: (x) {
// User pressed on the same tab twice
if (_navbarNotifier.index == x) {
_navbarNotifier.popAllRoutes(x);
} else {
_navbarNotifier.index = x;
_controller.reset();
_controller.forward();
}
},
menuItems: menuItemlist),
),
],
);
}),
),
);
}
}
class NavbarNotifier extends ChangeNotifier {
int _index = 0;
int get index => _index;
bool _hideBottomNavBar = false;
set index(int x) {
_index = x;
notifyListeners();
}
bool get hideBottomNavBar => _hideBottomNavBar;
set hideBottomNavBar(bool x) {
_hideBottomNavBar = x;
notifyListeners();
}
// pop routes from the nested navigator stack and not the main stack
// this is done based on the currentIndex of the bottom navbar
// if the backButton is pressed on the initial route the app will be terminated
FutureOr<bool> onBackButtonPressed() async {
bool exitingApp = true;
switch (_navbarNotifier.index) {
case 0:
if (homeKey.currentState != null && homeKey.currentState!.canPop()) {
homeKey.currentState!.pop();
exitingApp = false;
}
break;
case 1:
if (productsKey.currentState != null &&
productsKey.currentState!.canPop()) {
productsKey.currentState!.pop();
exitingApp = false;
}
break;
case 2:
if (profileKey.currentState != null &&
profileKey.currentState!.canPop()) {
profileKey.currentState!.pop();
exitingApp = false;
}
break;
default:
return false;
}
if (exitingApp) {
return true;
} else {
return false;
}
}
// pops all routes except first, if there are more than 1 route in each navigator stack
void popAllRoutes(int index) {
switch (index) {
case 0:
if (homeKey.currentState!.canPop()) {
homeKey.currentState!.popUntil((route) => route.isFirst);
}
return;
case 1:
if (productsKey.currentState!.canPop()) {
productsKey.currentState!.popUntil((route) => route.isFirst);
}
return;
case 2:
if (profileKey.currentState!.canPop()) {
profileKey.currentState!.popUntil((route) => route.isFirst);
}
return;
default:
break;
}
}
}
class AnimatedNavBar extends StatefulWidget {
const AnimatedNavBar(
{Key? key,
required this.model,
required this.menuItems,
required this.onItemTapped})
: super(key: key);
final List<MenuItem> menuItems;
final NavbarNotifier model;
final Function(int) onItemTapped;
@override
_AnimatedNavBarState createState() => _AnimatedNavBarState();
}
class _AnimatedNavBarState extends State<AnimatedNavBar>
with SingleTickerProviderStateMixin {
@override
void didUpdateWidget(covariant AnimatedNavBar oldWidget) {
if (widget.model.hideBottomNavBar != isHidden) {
if (!isHidden) {
_showBottomNavBar();
} else {
_hideBottomNavBar();
}
isHidden = !isHidden;
}
super.didUpdateWidget(oldWidget);
}
void _hideBottomNavBar() {
_controller.reverse();
return;
}
void _showBottomNavBar() {
_controller.forward();
return;
}
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500), vsync: this)
..addListener(() => setState(() {}));
animation = Tween(begin: 0.0, end: 100.0).animate(_controller);
}
late AnimationController _controller;
late Animation<double> animation;
bool isHidden = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (BuildContext context, Widget? child) {
return Transform.translate(
offset: Offset(0, animation.value),
child: Container(
decoration: BoxDecoration(boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 12,
spreadRadius: 2,
offset: const Offset(2, -2),
),
]),
child: BottomNavigationBar(
type: BottomNavigationBarType.shifting,
currentIndex: widget.model.index,
onTap: (x) {
widget.onItemTapped(x);
},
elevation: 16.0,
showUnselectedLabels: true,
unselectedItemColor: Colors.white54,
selectedItemColor: Colors.white,
items: widget.menuItems
.map((MenuItem menuItem) => BottomNavigationBarItem(
backgroundColor: colors[widget.model.index],
icon: Icon(menuItem.iconData),
label: menuItem.text,
))
.toList(),
),
),
);
});
}
}
class HomeMenu extends StatelessWidget {
const HomeMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
colorScheme:
Theme.of(context).colorScheme.copyWith(primary: colors[0])),
child: Navigator(
key: homeKey,
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
WidgetBuilder builder;
switch (settings.name) {
case '/':
builder = (BuildContext _) => const HomeFeeds();
break;
case FeedDetail.route:
builder = (BuildContext _) {
final id = (settings.arguments as Map)['id'];
return FeedDetail(
feedId: id,
);
};
break;
default:
builder = (BuildContext _) => const HomeFeeds();
}
return MaterialPageRoute(builder: builder, settings: settings);
}),
);
}
}
class ProductsMenu extends StatelessWidget {
const ProductsMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
colorScheme:
Theme.of(context).colorScheme.copyWith(primary: colors[1])),
child: Navigator(
key: productsKey,
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
WidgetBuilder builder;
switch (settings.name) {
case '/':
builder = (BuildContext _) => const ProductList();
break;
case ProductDetail.route:
final id = (settings.arguments as Map)['id'];
builder = (BuildContext _) {
return ProductDetail(
id: id,
);
};
break;
case ProductComments.route:
final id = (settings.arguments as Map)['id'];
builder = (BuildContext _) {
return ProductComments(
id: id,
);
};
break;
default:
builder = (BuildContext _) => const ProductList();
}
return MaterialPageRoute(builder: builder, settings: settings);
}),
);
}
}
class ProfileMenu extends StatelessWidget {
const ProfileMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
colorScheme:
Theme.of(context).colorScheme.copyWith(primary: colors[2])),
child: Navigator(
key: profileKey,
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
WidgetBuilder builder;
switch (settings.name) {
case '/':
builder = (BuildContext _) => const UserProfile();
break;
case ProfileEdit.route:
builder = (BuildContext _) => const ProfileEdit();
break;
default:
builder = (BuildContext _) => const UserProfile();
}
return MaterialPageRoute(builder: builder, settings: settings);
}),
);
}
}
class HomeFeeds extends StatefulWidget {
const HomeFeeds({Key? key}) : super(key: key);
static const String route = '/';
@override
State<HomeFeeds> createState() => _HomeFeedsState();
}
class _HomeFeedsState extends State<HomeFeeds> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_addScrollListener();
}
void _addScrollListener() {
_scrollController.addListener(() {
if (_scrollController.position.userScrollDirection ==
ScrollDirection.forward) {
if (_navbarNotifier.hideBottomNavBar) {
_navbarNotifier.hideBottomNavBar = false;
}
} else {
if (!_navbarNotifier.hideBottomNavBar) {
_navbarNotifier.hideBottomNavBar = true;
}
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Feeds'),
),
body: ListView.builder(
controller: _scrollController,
itemCount: 30,
itemBuilder: (context, index) {
return InkWell(
onTap: () {
_navbarNotifier.hideBottomNavBar = false;
navigate(context, FeedDetail.route,
isRootNavigator: false,
arguments: {'id': index.toString()});
},
child: FeedTile(index: index));
},
),
);
}
}
class FeedTile extends StatelessWidget {
final int index;
const FeedTile({Key? key, required this.index}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 300,
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
color: Colors.grey.withOpacity(0.4),
child: Stack(
children: [
Positioned(
top: 4,
right: 4,
left: 4,
child: Container(
color: Colors.grey,
height: 180,
),
),
Positioned(
bottom: 12,
right: 12,
left: 12,
child: Text(placeHolderText.substring(0, 200)))
],
),
);
}
}
class FeedDetail extends StatelessWidget {
final String feedId;
const FeedDetail({Key? key, this.feedId = '1'}) : super(key: key);
static const String route = '/feeds/detail';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Feed $feedId'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: const [
Placeholder(
fallbackHeight: 200,
fallbackWidth: 300,
),
Text(placeHolderText),
],
),
),
),
);
}
}
class ProductList extends StatefulWidget {
const ProductList({Key? key}) : super(key: key);
static const String route = '/';
@override
State<ProductList> createState() => _ProductListState();
}
class _ProductListState extends State<ProductList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_addScrollListener();
}
void _addScrollListener() {
_scrollController.addListener(() {
if (_scrollController.position.userScrollDirection ==
ScrollDirection.forward) {
if (_navbarNotifier.hideBottomNavBar) {
_navbarNotifier.hideBottomNavBar = false;
}
} else {
if (!_navbarNotifier.hideBottomNavBar) {
_navbarNotifier.hideBottomNavBar = true;
}
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
),
body: ListView.builder(
controller: _scrollController,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () {
_navbarNotifier.hideBottomNavBar = false;
navigate(context, ProductDetail.route,
isRootNavigator: false,
arguments: {'id': index.toString()});
},
child: ProductTile(index: index)),
);
}),
);
}
}
class ProductTile extends StatelessWidget {
final int index;
const ProductTile({Key? key, required this.index}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
color: Colors.grey.withOpacity(0.5),
height: 120,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
margin: const EdgeInsets.all(8),
height: 75,
width: 75,
color: Colors.grey,
),
Text('Product $index'),
],
));
}
}
class ProductDetail extends StatelessWidget {
final String id;
const ProductDetail({Key? key, this.id = '1'}) : super(key: key);
static const String route = '/products/detail';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Product $id'),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text('My AWESOME Product $id'),
const Center(
child: Placeholder(
fallbackHeight: 200,
fallbackWidth: 300,
),
),
TextButton(
onPressed: () {
_navbarNotifier.hideBottomNavBar = false;
navigate(context, ProductComments.route,
isRootNavigator: false, arguments: {'id': id.toString()});
},
child: const Text('show comments'))
],
),
);
}
}
class ProductComments extends StatelessWidget {
final String id;
const ProductComments({Key? key, this.id = '1'}) : super(key: key);
static const String route = '/products/detail/comments';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Comments on Product $id'),
),
body: ListView.builder(itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: 60,
child: ListTile(
tileColor: Colors.grey.withOpacity(0.5),
title: Text('Comment $index'),
),
),
);
}),
);
}
}
class UserProfile extends StatelessWidget {
static const String route = '/';
const UserProfile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
navigate(context, ProfileEdit.route);
},
)
],
title: const Text('Hi User')),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hi My Name is'),
SizedBox(
width: 10,
),
SizedBox(
width: 100,
child: TextField(
decoration: InputDecoration(),
),
),
],
),
],
),
),
);
}
}
class ProfileEdit extends StatelessWidget {
static const String route = '/profile/edit';
const ProfileEdit({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile Edit')),
body: const Center(
child: Text('Notice this page does not have bottom navigation bar'),
),
);
}
}
@maheshmnj
Copy link
Author

maheshmnj commented May 27, 2022

  • Ability to push routes in the nested or root navigator
  • Android back button handling
  • hide the bottom navbar in a long list
  • maintain state across bottom navbar tabs
  • Tapping the same navbar button pops to base route of nested navigator (same as instagram)
  • Double-tap the back button to exit the app.
  • Switch the Navbar destination with animation

output

Screen.Recording.2022-05-30.at.9.10.56.AM_Trim.mp4

@maheshmnj
Copy link
Author

update: I published this as a package https://pub.dev/packages/navbar_router to avoid the hassle to write all the boilerplate to achieve above features

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment