Flutter persistent navigation bar with named routes? - dart

I've been searching around for a good navigation/router example for Flutter but I have not managed to find one.
What I want to achieve is very simple:
Persistent bottom navigation bar that highlights the current top level route
Named routes so I can navigate to any route from anywhere inside the app
Navigator.pop should always take me to the previous view I was in
The official Flutter demo for BottomNavigationBar achieves 1 but back button and routing dont't work. Same problem with PageView and TabView. There are many other tutorials that achieve 2 and 3 by implementing MaterialApp routes but none of them seem to have a persistent navigation bar.
Are there any examples of a navigation system that would satisfy all these requirements?

All of your 3 requirements can be achieved by using a custom Navigator.
The Flutter team did a video on this, and the article they followed is here: https://medium.com/flutter/getting-to-the-bottom-of-navigation-in-flutter-b3e440b9386
Basically, you will need to wrap the body of your Scaffold in a custom Navigator:
class _MainScreenState extends State<MainScreen> {
final _navigatorKey = GlobalKey<NavigatorState>();
// ...
#override
Widget build(BuildContext context) {
return Scaffold(
body: Navigator(
key: _navigatorKey,
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
WidgetBuilder builder;
// Manage your route names here
switch (settings.name) {
case '/':
builder = (BuildContext context) => HomePage();
break;
case '/page1':
builder = (BuildContext context) => Page1();
break;
case '/page2':
builder = (BuildContext context) => Page2();
break;
default:
throw Exception('Invalid route: ${settings.name}');
}
// You can also return a PageRouteBuilder and
// define custom transitions between pages
return MaterialPageRoute(
builder: builder,
settings: settings,
);
},
),
bottomNavigationBar: _yourBottomNavigationBar,
);
}
}
Within your bottom navigation bar, to navigate to a new screen in the new custom Navigator, you just have to call this:
_navigatorKey.currentState.pushNamed('/yourRouteName');
To achieve the 3rd requirement, which is Navigator.pop taking you to the previous view, you will need to wrap the custom Navigator with a WillPopScope:
#override
Widget build(BuildContext context) {
return Scaffold(
body: WillPopScope(
onWillPop: () async {
if (_navigatorKey.currentState.canPop()) {
_navigatorKey.currentState.pop();
return false;
}
return true;
},
child: Navigator(
// ...
),
),
bottomNavigationBar: _yourBottomNavigationBar,
);
}
And that should be it! No need to manually handle pop or manage a custom history list.

CupertinoTabBar behave exactly same as you described, but in iOS style. It can be used in MaterialApps however.
Sample Code

What you are asking for would violate the material design specification.
On Android, the Back button does not navigate between bottom
navigation bar views.
A navigation drawer would give you 2 and 3, but not 1. It depends on what's more important to you.
You could try using LocalHistoryRoute. This achieves the effect you want:
class MainPage extends StatefulWidget {
#override
State createState() {
return new MainPageState();
}
}
class MainPageState extends State<MainPage> {
int _currentIndex = 0;
List<int> _history = [0];
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('Bottom Nav Back'),
),
body: new Center(
child: new Text('Page $_currentIndex'),
),
bottomNavigationBar: new BottomNavigationBar(
currentIndex: _currentIndex,
items: <BottomNavigationBarItem>[
new BottomNavigationBarItem(
icon: new Icon(Icons.touch_app),
title: new Text('keypad'),
),
new BottomNavigationBarItem(
icon: new Icon(Icons.assessment),
title: new Text('chart'),
),
new BottomNavigationBarItem(
icon: new Icon(Icons.cloud),
title: new Text('weather'),
),
],
onTap: (int index) {
_history.add(index);
setState(() => _currentIndex = index);
Navigator.push(context, new BottomNavigationRoute()).then((x) {
_history.removeLast();
setState(() => _currentIndex = _history.last);
});
},
),
);
}
}
class BottomNavigationRoute extends LocalHistoryRoute<void> {}

Related

How to detect re-select event and navigate to the first page in the cupetinotab in flutter

I created bottom navigation for iOS using CupetinoTabBar in flutter and I'd like to detect re-select tab event and go back to the its first page.
Is there any way to implement the function?
For iOS, it's a pretty common function but I don't know how to do it.
The code is below.
Thank you in advance.
class _MainTabScreenState extends State<MainTabScreen> {
final List<Widget> _pages = [FeedScreen(), Feed2Screen(), HomeScreen()];
#override
Widget build(BuildContext context) {
return CupertinoTabScaffold(
tabBar: CupertinoTabBar(
backgroundColor: Colors.black,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.home)),
BottomNavigationBarItem(icon: Icon(Icons.movie)),
BottomNavigationBarItem(icon: Icon(Icons.person)),
],
),
tabBuilder: (BuildContext context, int index) {
return CupertinoTabView(
builder: (ctx) {
return _pages[index];
},
);
},
);
}
}

How to persist BottomNavigationBar when using Flutter navigation and routes

I'm making a project with 4 different pages. I use a "BottomNavigationBar" widget to navigate to each page. When an icon in the "BottomNavigationBar" is pressed, I display a different page in the Scaffold body. I don't use any routing so when a user presses back on Android, the app closes. Something I don't want happening.
All the guides I have found reload the whole "Scaffold" when navigating, but I want to only update the "body" property of the "Scaffold" widget. When a Navigation.pop() occurs I again only want the "Scaffold" body to change.
I have found a post around the same issue, but the answer didn't work for me.
Another workaround I can try is making a custom history list, that I then update when pages are changed. Catching OnWillPop event to update the pages when the back button is pressed. I haven't tried this because I feel like there has to be a better way.
The Scaffold widget that displays the page.
Widget createScaffold() {
return Scaffold(
backgroundColor: Colors.white,
appBar: EmptyAppBar(),
body: _displayedPage,
bottomNavigationBar: createBottomNavigationbar(),
);
}
The BottomNavigationBar widget.
Widget createBottomNavigationbar() {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: [
BottomNavigationBarItem(
icon: new Icon(Icons.home,
color: _selectedIndex == 0 ? selectedColor : unselectedColor),
title: new Text('Home',
style: new TextStyle(
color:
_selectedIndex == 0 ? selectedColor : unselectedColor)),
),
BottomNavigationBarItem(
icon: new Icon(Icons.show_chart,
color: _selectedIndex == 1 ? selectedColor : unselectedColor),
title: new Text('Month',
style: new TextStyle(
color:
_selectedIndex == 1 ? selectedColor : unselectedColor)),
),
BottomNavigationBarItem(
icon: Icon(Icons.history,
color: _selectedIndex == 2 ? selectedColor : unselectedColor),
title: Text('History',
style: new TextStyle(
color: _selectedIndex == 2
? selectedColor
: unselectedColor))),
BottomNavigationBarItem(
icon: Icon(Icons.settings,
color: _selectedIndex == 3 ? selectedColor : unselectedColor),
title: Text('Settings',
style: new TextStyle(
color: _selectedIndex == 3
? selectedColor
: unselectedColor)))
],
);
}
Methods that update the state of the displayed page.
void _onItemTapped(int index) {
_changeDisplayedScreen(index);
setState(() {
_selectedIndex = index;
});
}
void _changeDisplayedScreen(int index) {
switch (index) {
case 0:
setState(() {
_displayedPage = new LatestReadingPage();
});
break;
case 1:
setState(() {
_displayedPage = new HomeScreen();
//Placeholder
});
break;
case 2:
setState(() {
_displayedPage = new HomeScreen();
//Placeholder
});
break;
case 3:
setState(() {
_displayedPage = new HomeScreen();
//Placeholder
});
break;
default:
setState(() {
_displayedPage = new LatestReadingPage();
});
break;
}
}
}
What I want is to be able to use the Flutter Navigation infrastructure, but only update the body property of the Scaffold widget when changing pages. Instead of the whole screen.
A lot like the Youtube app or Google news app.
I have added an answer to the post that you linked: https://stackoverflow.com/a/59133502/6064621
The answer below is similar to the one above, but I've also added unknown routes here:
What you want can be achieved by using a custom Navigator.
The Flutter team did a video on this, and the article they followed is here: https://medium.com/flutter/getting-to-the-bottom-of-navigation-in-flutter-b3e440b9386
Basically, you will need to wrap the body of your Scaffold in a custom Navigator:
class _MainScreenState extends State<MainScreen> {
final _navigatorKey = GlobalKey<NavigatorState>();
// ...
#override
Widget build(BuildContext context) {
return Scaffold(
body: Navigator(
key: _navigatorKey,
initialRoute: '/',
onGenerateRoute: (RouteSettings settings) {
WidgetBuilder builder;
// Manage your route names here
switch (settings.name) {
case '/':
builder = (BuildContext context) => HomePage();
break;
case '/page1':
builder = (BuildContext context) => Page1();
break;
case '/page2':
builder = (BuildContext context) => Page2();
break;
default:
throw Exception('Invalid route: ${settings.name}');
}
// You can also return a PageRouteBuilder and
// define custom transitions between pages
return MaterialPageRoute(
builder: builder,
settings: settings,
);
},
),
bottomNavigationBar: _yourBottomNavigationBar,
);
}
}
Within your bottom navigation bar, to navigate to a new screen in the new custom Navigator, you just have to call this:
_navigatorKey.currentState.pushNamed('/yourRouteName');
If you don't used named routes, then here is what you should do for your custom Navigator, and for navigating to new screens:
// Replace the above onGenerateRoute function with this one
onGenerateRoute: (RouteSettings settings) {
return MaterialPageRoute(
builder: (BuildContext context) => YourHomePage(),
settings: settings,
);
},
_navigatorKey.currentState.push(MaterialPageRoute(
builder: (BuildContext context) => YourNextPage(),
));
To let Navigator.pop take you to the previous view, you will need to wrap the custom Navigator with a WillPopScope:
#override
Widget build(BuildContext context) {
return Scaffold(
body: WillPopScope(
onWillPop: () async {
if (_navigatorKey.currentState.canPop()) {
_navigatorKey.currentState.pop();
return false;
}
return true;
},
child: Navigator(
// ...
),
),
bottomNavigationBar: _yourBottomNavigationBar,
);
}
And that should be it! No need to manually handle pop too much or manage a custom history list.
wrap your body inside the IndexedStack widget. Like this:
body: IndexedStack(
index: selectedIndex,
children: _children, //define a widget children list
),
Here's a link https://medium.com/flutter/getting-to-the-bottom-of-navigation-in-flutter-b3e440b9386
Have you tried this?
return Scaffold(
backgroundColor: Colors.white,
appBar: EmptyAppBar(),
body: _displayedPage[_selectedIndex],
bottomNavigationBar: _createBottomNavigationbar,
);
Then you make a widget list of all the Screens you want:
List<Widget> _displayedPage = [LatestReadingPage(), HomeScreen(), ExampleScreen()];

How to programmatically open a Drawer by tapping on a BottomNavigationBarItem?

I'm making a flutter app and I need to be able to open the Drawer by tapping on a BottomNavigationBarItem. Is there any way to do that?
The UX designer guy put the drawer menu icon at index 0 in the bottom navigation bar. I tried to find an answer in the Flutter documentation but I didn't find anything relevant. I actually found a way of opening it programmatically (as you can see below) but it does not work like that in my case.
class _HomeState extends State<Home> {
int _currentIndex = 1; // 0 = menu
final List<Widget> _children = [
PlaceholderWidget(Colors.deepPurple),
PlaceholderWidget(Colors.white),
DiagnosisWidget(),
FindUsWidget(),
];
_navItem(String text, IconData icon) {
return BottomNavigationBarItem(
/* Building Bottom nav item */
);
}
void onTabTapped(int index) {
setState(() {
if(index == 0) {
Scaffold.of(context).openDrawer(); // This is what I've tried
}
else {
_currentIndex = index;
}
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
drawer: Drawer(
child: MyDrawer(),
),
body: _children[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
onTap: onTabTapped,
currentIndex: _currentIndex,
type: BottomNavigationBarType.fixed, // 4+ items in the bar
items: [
_navItem('MENU', Icons.menu),
_navItem('HOME', Icons.home),
_navItem('DIAGNOSIS', Icons.person),
_navItem('FIND US', Icons.location_on),
],
),
);
}
}
Instead of having the Drawer showing up, I get the following error message :
Scaffold.of() called with a context that does not contain a Scaffold.
It's because in onTabTapped you use a context that doesn't contain the Scaffold you create.
You instantiate the Scaffold in build but in onTabTapped you're looking for a parent Scaffold in the current context (_HomeState context).
You can use Builder inside the Scaffold to get the correct context or use a GlobalKey on your Scaffold.
See this answer for more details.
EDIT:
In your case a GlobalKey is mush easier to implement.
You can do the following :
class _HomeState extends State<Home> {
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>(); // ADD THIS LINE
int _currentIndex = 1; // 0 = menu
final List<Widget> _children = [
PlaceholderWidget(Colors.deepPurple),
PlaceholderWidget(Colors.white),
DiagnosisWidget(),
FindUsWidget(),
];
_navItem(String text, IconData icon) {
return BottomNavigationBarItem(
/* Building Bottom nav item */
);
}
void onTabTapped(int index) {
setState(() {
if(index == 0) {
_scaffoldKey.currentState.openDrawer(); // CHANGE THIS LINE
}
else {
_currentIndex = index;
}
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey, // ADD THIS LINE
drawer: Drawer(
child: MyDrawer(),
),
body: _children[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
onTap: onTabTapped,
currentIndex: _currentIndex,
type: BottomNavigationBarType.fixed, // 4+ items in the bar
items: [
_navItem('MENU', Icons.menu),
_navItem('HOME', Icons.home),
_navItem('DIAGNOSIS', Icons.person),
_navItem('FIND US', Icons.location_on),
],
),
);
}
}

bottomNavigationBar keeps rebuilding multiple times when calling Navigator.pushNamed

I have a problem with the structure of Flutter project.
At the moment structure looks like this:
Homepage with bottomNavigationBar with multiple tabs, each tab is StatefulWidget and contains some heavy processing (remote API calls and data display).
In case I call Navigator.pushNamed from inside of any tab, following happens:
All tabs are being rebuild in the background (making API calls, etc).
New page opens normally.
When I press back button, page closes and all tabs are rebuild again.
So in total everything (each tab) is rebuilt 2 times just to open external navigator page.
Is this some sort of bug? Completely not understandable why it's fully rebuilding bottomNavigationBar just before pushing new route.
How it should work:
When I call Navigator.pushNamed from inside the tab, new page should be open and all bottomNavigationBar tabs should not be rebuild and stay in unchanged state.
When I press back, page should close and user return to the same state of bottomNavigationBar and it's tabs, no rebuilding at all.
Is this possible to achieve?
Here is the code:
class HomePage extends StatefulWidget {
#override
_HomePageState createState() => new _HomePageState();
}
class _HomePageState extends State<HomePage> {
int index = 0;
final _tab1 = new tab1(); //StatefulWidget, api calls, heavy data processing
final _tab2 = new tab2(); //StatefulWidget, api calls, heavy data processing
#override
Widget build(BuildContext context) {
debugPrint('homepage loaded:'+index.toString());
return new Scaffold(
body: new Stack(
children: <Widget>[
new Offstage(
offstage: index != 0,
child: new TickerMode(
enabled: index == 0,
child: _tab1,
),
),
new Offstage(
offstage: index != 1,
child: new TickerMode(
enabled: index == 1,
child: _tab2,
),
),
],
),
bottomNavigationBar: new BottomNavigationBar(
currentIndex: index,
type: BottomNavigationBarType.fixed,
onTap: (int index) { setState((){ this.index = index; }); },
items: <BottomNavigationBarItem>[
new BottomNavigationBarItem(
icon: new Icon(Icons.live_help),
title: new Text("Tab1"),
),
new BottomNavigationBarItem(
icon: new Icon(Icons.favorite_border),
title: new Text("Tab 2"),
),
],
),
);
}
}
Here is single tab code:
class tab1 extends StatefulWidget {
#override
tab1State createState() => new tab1State();
}
class tab1State extends State<tab1> {
#override
Widget build(BuildContext cntx) {
debugPrint('tab loaded'); //this gets called when Navigator.pushNamed called and when back button pressed
//some heave processing with http.get here...
//...
return new Center(
child: new RaisedButton(
onPressed: () {
Navigator.pushNamed(context, '/some_other_page');
},
child: new Text('Open new page'),
));
}
}
In build you should simply be building the Widget tree, not doing any heavy processing. Your tab1 is a StatefulWidget, so its state should be holding onto the current state (including results of your heavy processing). Its build should simply be rendering the current version of that state.
In tab1state, override initState to set initial values, and possibly start some async functions to start doing the fetching - calling setState once the results are available. In build, render whatever the current state is, bearing in mind that it may only be partially available as the heavy work continues in the background. So, for example, test for values being null and maybe replace them with progress indicators or empty Containers.
You can get more sophisticated by using StreamBuilder and FutureBuilder which make the fetch/setState/(possibly partial)render more elegant.
Since you are building BottomNavigationBar inside build function, it will be rebuilt every time state changes.
To avoid this you can build BottomNavigationBar inside initState() method as given below,
class HomePage extends StatefulWidget {
#override
_HomePageState createState() => new _HomePageState();
}
class _HomePageState extends State<HomePage> {
BottomNavigationBar _bottomNavigationBar;
#override
void initState() {
super.initState();
_bottomNavigationBar = _buildBottomNavigationBar();
}
#override
Widget build(BuildContext context) {
return Scaffold(
body: new Center(child: new Text('Hello', style: new TextStyle(decoration: TextDecoration.underline),),),
bottomNavigationBar: _bottomNavigationBar, // Use already created BottomNavigationBar rather than creating a new one
);
}
// Create BottomNavigationBar
BottomNavigationBar _buildBottomNavigationBar() {
return new BottomNavigationBar(
items: [
new BottomNavigationBarItem(
icon: new Icon(Icons.add),
title: new Text("trends")
),
new BottomNavigationBarItem(
icon: new Icon(Icons.location_on),
title: new Text("feed")
),
new BottomNavigationBarItem(
icon: new Icon(Icons.people),
title: new Text("community")
)
],
onTap: (index) {},
);
}
}
You can use any one of these to save the states and precvent rebuilding:
IndexedStack https://api.flutter.dev/flutter/widgets/IndexedStack-class.html
PageStorageKey https://api.flutter.dev/flutter/widgets/PageStorageKey-class.html
AutomaticKeepAliveClientMixin https://api.flutter.dev/flutter/widgets/AutomaticKeepAliveClientMixin-mixin.html

Replace initial Route in MaterialApp without animation?

Our app is built on top of Scaffold and to this point we have been able to accommodate most of our routing and navigation requirements using the provided calls within NavigatorState (pushNamed(), pushReplacementNamed(), etc.). What we don't want though, is to have any kind of 'push' animation when a user selects an item from our drawer (nav) menu. We want the destination screen from a nav menu click to effectively become the new initial route of the stack. For the moment we are using pushReplacementNamed() for this to ensure no back arrow in the app bar. But, the slide-in-from-the-right animation implies a stack is building.
What is our best option for changing that initial route without animation, and, can we do that while also concurrently animating the drawer closed? Or are we looking at a situation here where we need to move away from Navigator over to just using a single Scaffold and updating the 'body' directly when the user wants to change screens?
We note there is a replace() call on NavigatorState which we assume might be the right place to start looking, but it's unclear how to access our various routes originally set up in new MaterialApp(). Something like replaceNamed() might be in order ;-)
What you're doing sounds somewhat like a BottomNavigationBar, so you might want to consider one of those instead of a Drawer.
However, having a single Scaffold and updating the body when the user taps a drawer item is a totally reasonable approach. You might consider a FadeTransition to change from one body to another.
Or, if you like using Navigator but don't want the default slide animation, you can customize (or disable) the animation by extending MaterialPageRoute. Here's an example of that:
import 'package:flutter/material.dart';
void main() {
runApp(new MyApp());
}
class MyCustomRoute<T> extends MaterialPageRoute<T> {
MyCustomRoute({ WidgetBuilder builder, RouteSettings settings })
: super(builder: builder, settings: settings);
#override
Widget buildTransitions(BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child) {
if (settings.isInitialRoute)
return child;
// Fades between routes. (If you don't want any animation,
// just return child.)
return new FadeTransition(opacity: animation, child: child);
}
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Navigation example',
onGenerateRoute: (RouteSettings settings) {
switch (settings.name) {
case '/': return new MyCustomRoute(
builder: (_) => new MyHomePage(),
settings: settings,
);
case '/somewhere': return new MyCustomRoute(
builder: (_) => new Somewhere(),
settings: settings,
);
}
assert(false);
}
);
}
}
class MyHomePage extends StatelessWidget {
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('Navigation example'),
),
drawer: new Drawer(
child: new ListView(
children: <Widget> [
new DrawerHeader(
child: new Container(
child: const Text('This is a header'),
),
),
new ListTile(
leading: const Icon(Icons.navigate_next),
title: const Text('Navigate somewhere'),
onTap: () {
Navigator.pushNamed(context, '/somewhere');
},
),
],
),
),
body: new Center(
child: new Text(
'This is a home page.',
),
),
);
}
}
class Somewhere extends StatelessWidget {
Widget build(BuildContext context) {
return new Scaffold(
body: new Center(
child: new Text(
'Congrats, you did it.',
),
),
appBar: new AppBar(
title: new Text('Somewhere'),
),
drawer: new Drawer(
child: new ListView(
children: <Widget>[
new DrawerHeader(
child: new Container(
child: const Text('This is a header'),
),
),
],
),
),
);
}
}
Use PageRouteBuilder like:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, __, ___) => Screen2(),
transitionDuration: Duration.zero,
),
);
And if you want transition, simply add following property to above PageRouteBuilder, and change seconds to say 1.
transitionsBuilder: (_, a, __, c) => FadeTransition(opacity: a, child: c),

Resources