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
Related
This is my code:
class HomeCardList extends StatefulWidget {
final Location location;
final Position position;
HomeCardList(this.location, this.position);
#override
_HomeCardListState createState() => _HomeCardListState();
}
class _HomeCardListState extends State<HomeCardList> {
List<Widget> _homeCards = [TimeDateCard(), TimeDateCard()];
#override
Widget build(BuildContext context) {
return new CustomScrollView(
slivers: <Widget>[
new LocationAppbar(
location: widget.location,
position: widget.position,
),
new SliverList(
delegate: new SliverChildListDelegate(_homeCards),
),
new SliverToBoxAdapter(
child: RaisedButton(onPressed: () {
setState(() {
_homeCards.removeLast();
});
}),
),
new SliverFixedExtentList(
delegate: new SliverChildBuilderDelegate((context, index) {
return new Text('Item #$index');
}),
itemExtent: 320.0)
],
);
}
}
When i press the RaisedButton it does remove the last widget from the list i want to show (if i'll add a print() statement i'll see an entry was removed), but doesn't update the state as it should, i can still see 2 widgets. when i press the button the third time i get an exception for trying to remove an item from an empty list.
What am i doing wrong here? Why when i remove a widget from the list, the state doesn't update correctly and show only one widget?
Edit:
When i try something like
setState(){
_homeCards = [];
}
It does refreshes with an empty list, what's wrong here?
You need to make _homeCards a getter so it's computed lazily, then setState will trigger rebuild
I'm trying to create a widget that has a button and whenever that button is pressed, a list opens up underneath it filling in all of the space under the button. I implemented it with a simple Column, something like this:
class _MyCoolWidgetState extends State<MyCoolWidget> {
...
#override
Widget build(BuildContext context) {
return new Column(
children: <Widget>[
new MyButton(...),
isPressed ? new Expanded(
child: new SizedBox(
width: MediaQuery.of(context).size.width,
child: new MyList()
)
) : new Container()
]
)
}
}
This works totally fine in a lot of cases, but not all.
The problem I'm having with creating this widget is that if a MyCoolWidget is placed inside a Row for example with other widgets, lets say other MyCoolWidgets, the list is constrained by the width that the Row implies on it.
I tried fixing this with an OverflowBox, but with no luck unfortunately.
This widget is different from tabs in the sense that they can be placed anywhere in the widget tree and when the button is pressed, the list will fill up all the space under the button even if this means neglecting constraints.
The following image is a representation of what I'm trying to achieve in which "BUTTON1" and "BUTTON2" or both MyCoolWidgets in a Row:
Edit: Snippet of the actual code
class _MyCoolWidgetState extends State<MyCoolWidget> {
bool isTapped = false;
#override
Widget build(BuildContext context) {
return new Column(
children: <Widget>[
new SizedBox(
height: 20.0,
width: 55.0,
child: new Material(
color: Colors.red,
child: new InkWell(
onTap: () => setState(() => isTapped = !isTapped),
child: new Text("Surprise"),
),
),
),
bottomList()
],
);
}
Widget comboList() {
if (isTapped) {
return new Expanded(
child: new OverflowBox(
child: new Container(
color: Colors.orange,
width: MediaQuery.of(context).size.width,
child: new ListView( // Random list
children: <Widget>[
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
new Text("ok"),
],
)
)
),
);
} else {
return new Container();
}
}
}
I'm using it as follows:
class Home extends StatelessWidget {
#override
Widget build(BuildContext context) {
return new Row(
children: <Widget>[
new Expanded(child: new MyCoolWidget()),
new Expanded(child: new MyCoolWidget()),
]
)
}
}
Here is a screenshot of what the code is actually doing:
From the comments, it was clarified that what the OP wants is this:
Making a popup that covers everything and goes from wherever the button is on the screen to the bottom of the screen, while also filling it horizontally, regardless of where the button is on the screen. It would also toggle open/closed when the button is pressed.
There are a few options for how this could be done; the most basic would be to use a Dialog & showDialog, except that it has some issues around SafeArea that make that difficult. Also, the OP is asking for the button to toggle rather than pressing anywhere not the dialog (which is what dialog does - either that or blocks touches behind the dialog).
This is a working example of how to do something like this. Full disclaimer - I'm not stating that this is a good thing to do, or even a good way to do it... but it is a way to do it.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
// We're extending PopupRoute as it (and ModalRoute) do a lot of things
// that we don't want to have to re-create. Unfortunately ModalRoute also
// adds a modal barrier which we don't want, so we have to do a slightly messy
// workaround for that. And this has a few properties we don't really care about.
class NoBarrierPopupRoute<T> extends PopupRoute<T> {
NoBarrierPopupRoute({#required this.builder});
final WidgetBuilder builder;
#override
Color barrierColor;
#override
bool barrierDismissible = true;
#override
String barrierLabel;
#override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return new Builder(builder: builder);
}
#override
Duration get transitionDuration => const Duration(milliseconds: 100);
#override
Iterable<OverlayEntry> createOverlayEntries() sync* {
// modalRoute creates two overlays - the modal barrier, then the
// actual one we want that displays our page. We simply don't
// return the modal barrier.
// Note that if you want a tap anywhere that isn't the dialog (list)
// to close it, then you could delete this override.
yield super.createOverlayEntries().last;
}
#override
Widget buildTransitions(
BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
// if you don't want a transition, remove this and set transitionDuration to 0.
return new FadeTransition(opacity: new CurvedAnimation(parent: animation, curve: Curves.easeOut), child: child);
}
}
class PopupButton extends StatefulWidget {
final String text;
final WidgetBuilder popupBuilder;
PopupButton({#required this.text, #required this.popupBuilder});
#override
State<StatefulWidget> createState() => PopupButtonState();
}
class PopupButtonState extends State<PopupButton> {
bool _active = false;
#override
Widget build(BuildContext context) {
return new FlatButton(
onPressed: () {
if (_active) {
Navigator.of(context).pop();
} else {
RenderBox renderbox = context.findRenderObject();
Offset globalCoord = renderbox.localToGlobal(new Offset(0.0, context.size.height));
setState(() => _active = true);
Navigator
.of(context, rootNavigator: true)
.push(
new NoBarrierPopupRoute(
builder: (context) => new Padding(
padding: new EdgeInsets.only(top: globalCoord.dy),
child: new Builder(builder: widget.popupBuilder),
),
),
)
.then((val) => setState(() => _active = false));
}
},
child: new Text(widget.text),
);
}
}
class MyApp extends StatefulWidget {
#override
State<StatefulWidget> createState() => MyAppState();
}
class MyAppState extends State<MyApp> {
#override
Widget build(BuildContext context) {
return new MaterialApp(
home: new SafeArea(
child: new Container(
color: Colors.white,
child: new Column(children: [
new PopupButton(
text: "one",
popupBuilder: (context) => new Container(
color: Colors.blue,
),
),
new PopupButton(
text: "two",
popupBuilder: (context) => new Container(color: Colors.red),
)
]),
),
),
);
}
}
For even more outlandish suggestions, you can take the finding the location part of this and look at this answer which describes how to create a child that isn't constrained by it's parent's position.
However you end up doing this, it's probably best that the list not to be a direct child of the button as a lot of things in flutter depend on a child's sizing and making it be able to expand to the full screen size could quite easily cause problems.
I have the following screen:
import 'package:flutter/material.dart';
import '../models/patient.dart';
import '../components/patient_card.dart';
import '../services.dart';
class Home extends StatefulWidget {
var patients = <Patient>[];
#override
_HomeState createState() => new _HomeState();
}
class _HomeState extends State<Home> {
#override
initState() {
super.initState();
Services.fetchPatients().then((p) => setState(() => widget.patients = p));
}
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('Home'),
),
body: new Container(
child: new ListView(
children: widget.patients.map(
(patient) => new PatientCard(patient),
).toList()
)
)
);
}
}
As you can see I do the endpoint call when I overwrite initState() in _HomeState. But it only runs once initially when the app starts. I can't just type r in my terminal and let the app hot reload and call the endpoint again.. I have to use Shift + r to do a full restart first.
So the question is, am I calling the web service in the recommended spot? And if it not... where does it go? Also, shouldn't ListView have a function / property that gets called on "pull to refresh" or something?
As mentioned by #aziza you can use a Stream Builder or if you want to call a function every time widget gets built then you should call it in build function itself. Like in your case.
#override
Widget build(BuildContext context) {
Services.fetchPatients().then((p) => setState(() => widget.patients = p));
return new Scaffold(
appBar: new AppBar(
title: new Text('Home'),
),
body: new Container(
child: new ListView(
children: widget.patients.map(
(patient) => new PatientCard(patient),
).toList()
)
)
);
}
If you want to add pull-to-refresh functionality then wrap your widget in refresh indicator widget. Add your call in onRefresh property.
return new RefreshIndicator(child: //Your Widget Tree,
onRefresh: handleRefresh);
Note that this widget only works with vertical scroll view.
Hope it helps.
Have a look on StreamBuilder. This widget will allow you to deal with async data that are frequently updated and will update the UI accordingly by listening onValue at the end of your stream.
Flutter have FutureBuilder class, you can also create your widget as shown below
Widget build(BuildContext context) {
var futureBuilder = new FutureBuilder(
future: Services.fetchPatients().then((p) => setState(() => widget.patients = p)),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
if (snapshot.data != null) {
return new Container(
child: new ListView(
children: snapshot.data.map(
(patient) => new PatientCard(patient),
).toList()
)
);
}
} else {
return new Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(16.0),
child: new CircularProgressIndicator());
}
});
return new Container(child: futureBuilder);
}
Example project : Flutter - Using the future builder with list view.
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> {}
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),