Flutter: Laggy performance with PageView when updating the currentIndex in a BottomNavigationBar (but no lag if I don't update the currentIndex) - flutter

I'm creating an app with a Scaffold that contains:
A FutureBuilder in the body that creates a PageView as its child when data is loaded.
A BottomNavigationBar that syncs with the PageView for a more intuitive navigation.
Functionality-wise, everything works fine. I can swipe left and right between pages and the currentIndex gets updated correctly in the BottomNavigationBar, and if I tap on the BottomNavigationBar elements the PageView will animate to the correct page as expected.
However... performance is really bad when switching between pages, even in Profile mode.
After a lot of investigation, I've confirmed that lag is only present if I update the currentIndex of the BottomNavigationBar.
If I don't update the BottomNavigationBar, animations remain very smooth when switching between pages, both when swiping on the PageView and when tapping on the BottomNavigationBar elements themselves.
I can also confirm that this happens exactly the same when using setState and when using Provider. I was really hoping that it was just the setState method being inefficient... but no luck :(
For the setState impementation, this is what I'm doing:
On the PageView:
onPageChanged: (page) {
setState(() {
_selectedIndex = page;
});
}
On the BottomBarNavigation:
onTap: _onTappedBar,
currentIndex: _selectedIndex
and below:
void _onTappedBar(int value) {
_pageController.animateToPage(value, duration: Duration(milliseconds: 500), curve: Curves.ease);
setState(() {
_selectedIndex = value;
});
}
If I comment out both setState methods, the app becomes buttery smooth again and I can use the BottomNavigationBar correctly as well - it just doesn't update the selected item.
Interestingly enough, if I ONLY comment out the line inside both setState methods (_selectedIndex = page; and _selectedIndex = value;) but leave the methods there, the app still lags all the same even though the setState methods are completely empty and aren't updating anything...??
And this is the Provider version:
On the PageView:
onPageChanged: (page) {
Provider.of<BottomNavigationBarProvider>(context, listen: false).currentIndex = page;
}
On the BottomBarNavigation:
onTap: _onTappedBar,
currentIndex: Provider.of<BottomNavigationBarProvider>(context).currentIndex,
and below:
void _onTappedBar(int value) {
Provider.of<BottomNavigationBarProvider>(context, listen: false).currentIndex = value;
pageController.animateToPage(value, duration: Duration(milliseconds: 500), curve: Curves.ease);
}
As said, just as laggy as the setState version :(
Any idea of what's causing this lag and how to fix this? I really don't know what else to try.

Ok, so I think I managed to solve it while also learning a valuable lesson about Flutter!
I was on the right track with the setState / Provider dilemma - you do need to use Provider (or another state management solution) if you want to avoid rebuilding the whole page.
However, that's not enough.
In order to leverage the modularity of that implementation, you ALSO need to extract the relevant widget (in this case, the whole BottomNavigationBar) outside the main widget. If you don't, it seems everything on the main page will still get rebuilt, even if only a small widget is listening for Provider notifications.
So this is the structure of my root_screen's build method now (simplified body contents for readaibility):
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
children: <Widget>[
HomeScreen(),
PerformanceScreen(),
SettingsScreen(),
],
onPageChanged: (page) {
Provider.of<BottomNavigationBarProvider>(context, listen: false).currentIndex = page;
},
);
bottomNavigationBar: MyBottomNavigationBar(onTapped: _onTappedBar),
);
}
Notice how the bottomNavigationBar: parameter is no longer defined in this root_screen. Instead, I've created a new class (a StatelessWidget) in a separate Dart file that takes in an onTapped function as a parameter, and I'm instantiating it from here.
Said _onTappedBar function is defined right here on the root_screen, just below the build method:
void _onTappedBar(int value) {
Provider.of<BottomNavigationBarProvider>(context, listen: false).currentIndex = value;
_pageController.animateToPage(value, duration: Duration(milliseconds: 500), curve: Curves.ease);
}
And this is the separate Dart file containing the new MyBottomNavigationBar class:
class MyBottomNavigationBar extends StatelessWidget {
#override
const MyBottomNavigationBar({
Key key,
#required this.onTapped,
}) : super(key: key);
final Function onTapped;
Widget build(BuildContext context) {
return BottomNavigationBar(
items: [
BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('Home')),
BottomNavigationBarItem(
icon: Icon(Icons.trending_up), title: Text('Performance')),
BottomNavigationBarItem(
icon: Icon(Icons.settings), title: Text('Settings')),
],
onTap: onTapped,
currentIndex:
Provider.of<BottomNavigationBarProvider>(context).currentIndex,
);
}
}
Also for completeness (and because I absolutely needed to know), I tried using the setState approach again while keeping the BottomNavigationBar in its new separate file. I wanted to understand if simply extracting the widgets was enough to do the trick, or if you still need to use a state management solution no matter what.
It turns out... it wasn't enough! Performance using setState was horrible again, even though the BottomNavigationBar widget was extracted in its own class file.
So bottom line, in order to keep your app efficient and animations smooth, remember to extract widgets and modularise your Flutter code as much as possible, as well as using a state management solution instead of setState. That seems to be the only way to avoid unnecessary redraws (and your code will obviously be much cleaner and easier to debug).

Related

Flutter: push a new page and scroll down to certain widget

I have a web app in Flutter. My app bar has 5 buttons. Four of them scroll within the first page. The 5th one pushes a new page.
There are three different widgets on stage:
page A (HomeView)
page B (ContactView)
the AppBar
When the user is on page A, the buttons of page A should scroll down or up, and the button of page B should push the new view. When the user is on page B (contact) and presses any of the buttons of page A, it should push the home page and scroll to the required position.
I tried to do this with ScrollController.animateTo and Riverpod for state management, but I couldn't do it. The page scrolled down but, for some reason, it scrolled up again and didn't maintain position. I couldn't find any related post on the Internet and keepScrollOffset was not the answer. I also didn't know how to scroll to the desired position when the user is not on 0 (it scrolled down from current position).
After that, I tried to do it with GlobalKeys. It worked, up to a certain point. On page A, everything worked fine. However, from page B to page A, it said the keys are duplicated. I used pop instead of pushNamed as suggested in one of the answers of this post (the only answer that worked for me). The behavior was finally as expected, but I can't use pop since I have more pages on the website, so I can't ensure that the user is coming from page A. I tried with pushReplacement as the same answer gives that for an alternative option. It doesn't work now. When trying to go from page B (contact) to page A, it throws error Unexpected null value.. Furthermore, I need to keep the back button functionality since it is a website. I would prefer, then, to not pop the current page from the stack.
The page A (HomeView) is built like this inside of a StatefulWidget:
#override
Widget build(BuildContext context) {
return SingleChildScrollView(
controller: _scrollController,
child: Column(
children: [
HomeHeader(key: HomeView.homeKey),
WhatWeOffer(key: HomeView.servicesKey),
WhatWeDo(key: HomeView.referencesKey),
WhoWeAre(key: HomeView.aboutKey),
const LetsGetInTouch(),
],
),
);
}
The GlobalKeys are defined like this inside of the Page A (HomeView) widget:
static GlobalKey homeKey = GlobalKey(debugLabel: 'homeHeaderKey');
static GlobalKey servicesKey = GlobalKey(debugLabel: 'whatWeOfferKey');
static GlobalKey referencesKey = GlobalKey(debugLabel: 'whatWeDoKey');
static GlobalKey aboutKey = GlobalKey(debugLabel: 'whoWeAreKey');
This is how I implement the navigation to page A:
if (ModalRoute.of(context)?.settings.name != '/') {
Navigator.pushReplacementNamed(
context,
Navigation(context).routes["/"]!,
);
}
Scrollable.ensureVisible(
HomeView.homeKey.currentContext!,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
And this is how I implement the navigation to page B:
Navigator.pushReplacementNamed(
context,
Navigation(context).routes["/contact"]!,
),
Maybe GlobalKey is not the right approach to my problem? Is there any easier way to push a new page and scroll down to a widget?
EDIT:
I think the Unexpected null value problem was caused because the view was not built yet when I was trying to call it. I solved it by making the functions async and waiting before using the keys:
TextButton(
child: Text('home', style: style),
onPressed: () async {
if (ModalRoute.of(context)?.settings.name != '/') {
Navigator.pushReplacementNamed(
context,
Navigation(context).routes['/']!,
);
await Future.delayed(const Duration(milliseconds: 500));
}
Scrollable.ensureVisible(
HomeView.homeKey.currentContext!,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
},
),
However, this does not solve my problem of the back button. As I use pushReplacementNamed instead of pushNamed, I can't go back. Is there a more optimal way to do this, where I can keep the back button functionality?

Jumping to a new page in PageView.builder after the additon of a new page doesn't seem to work as expected [Flutter]

I have a simple layout that consists of a PageView.builder widget that contains several pages. The contents of each page are simple, they contain a Container and a Text widget inside of it.
cards is a List of type String and the Pageview.builder widget has an itemCount that's based on the length of this List. The value of the Text in each page is assigned using this List.
List<String> cards = [];
Now, whenever I add a new value to cards List, a variable newPage is used to store the last index position in the List after that element has been added.
After doing this, setState(() {}); is called so that the UI along with the PageView update to reflect the changes made in the List.
The PageView widget does reflect the changes and a new page does get added to it.
However, the problem arises when I try to jump to newly added page right after calling setState.
The error indicates that the index value that jumpToPage is trying to use is out of range in the PageView
cards.add("New card");
newPage = cards.length - 1;
setState(() {});
card_PageController.jumpToPage(newPage);
So, after trying to figure something out, I added a Timer after the setState, so that the framework get's some time to properly update the UI elements.
I'm using a small value of 50 milliseconds in the Timer function and jumping to the new page after the timer gets over.
cards.add("New card");
newPage = cards.length - 1;
setState(() {});
Timer(Duration(milliseconds: 50), () {
card_PageController.jumpToPage(newPage);
})
The addition of a Timer seems to solve the problem and there were no errors after it's addition. However, I'm not sure if this is the right way of tackling this problem.
I'd like to know as to why is this happening, as shouldn't calling jumpToPage directly after setState work without the use of a Timer?
Also, does setState infact take some time to finish updating the UI, even though it isn't async, and that due to this reason the referenced index is invalid? And could this problem have been tackled in a better way?
From the code you shared I can't detect an issue. Especially because I've reproduced what you said you wanted to achieve and it works without issue. Take a look at the code below:
class PageCards extends StatefulWidget {
#override
_PageCardsState createState() => _PageCardsState();
}
class _PageCardsState extends State<PageCards> {
PageController _pageController = PageController();
List<String> cards = [
'page 0',
'page 1',
'page 2',
];
#override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: cards.length,
itemBuilder: (context, index){
return Center(
child: Text(cards[index]),
);
}
),
),
RaisedButton(
child: Text('Add page'),
onPressed: () => addCard(),
),
],
);
}
void addCard(){
setState(() {
cards.add('page ${cards.length}');
});
_pageController.jumpToPage(cards.length - 1);
}
}

Flutter navigation best practice

I have a question for flutter developers that they have experience with angular or android sdk.
in android when you want to navigate between different screens without decreasing the performance we use fragments and we change them using frame layout
same thing for angular, we use router outlet and we navigate between components using routes
I want to implement the same thing with flutter (ps: I sow some articles like this one where the author navigate between his pages by updating the state and showing the right page conditionally witch I don't think that is a good practice)
So my question is there any way to use navigation in flutter like we do in angular or android ?
Thank you
Actually, there is no such thing as you said "Navigate just the specific area" in Flutter. Usually, I develop android apps, and I didn't see that in android either. Yes, you can change the fragment but this action is not navigation. It is kind of replace. If you try to replace the widget (or fragment) in flutter yes you can but, it is not navigation. Navigation is something else. Navigation is replacing the whole page.
Flutter focuses on widget's specific states. You can change every widget's state at any time. So, maybe you should change your perspective.
I have an app with multiple pages, I have used RouteGenerator to manges these routes, I also come from Angular background and found this to be the best approach as all my routes are located in one file.
https://flutter.dev/docs/cookbook/navigation/named-routes
You could use PageView.
PageController pageController = PageController(initialPage: 0);
int _selectedIndex = 0;
void _selectCurrentTab(int index) {
setState(() {
_selectedIndex = index;
});
pageController.animateToPage(_selectedIndex,
duration: Duration(milliseconds: 250), curve: Curves.linear);
}
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: ...,
body: PageView(
pageSnapping: true,
controller: pageController,
children: [HomePage(), AboutPage()],
),
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(...),
BottomNavigationBarItem(...)
],
type: BottomNavigationBarType.fixed,
showUnselectedLabels: true,
currentIndex: _selectedIndex,
onTap: _selectCurrentTab,
)));
}
What you need is Nested Routing. Check this out Link on how to implement Nested Routing using Navigator 2.0.

Flutter TextEditingController does not scroll above keyboard

In android version, Flutter TextEditingController does not scroll above keyboard like default text fields do when you start typing in field. I tried to look in sample apps provided in flutter example directory, but even there are no example of TextEditController with such behaviour.
Is there any way to implement this.
Thanks in advance.
so simple
if your textfields is between 5-10 fields
SingleChildScrollView(
reverse: true, // add this line in scroll view
child: ...
)
(August 20, 2021 Flutter 2.2.3)
I think my answer might be the cleanest solution for this problem:
#override
Widget build(BuildContext context) {
/// Get the [BuildContext] of the currently-focused
/// input field anywhere in the entire widget tree.
final focusedCtx = FocusManager.instance.primaryFocus!.context;
/// If u call [ensureVisible] while the keyboard is moving up
/// (the keyboard's display animation does not yet finish), this
/// will not work. U have to wait for the keyboard to be fully visible
Future.delayed(const Duration(milliseconds: 400))
.then((_) => Scrollable.ensureVisible(
focusedCtx!,
duration: const Duration(milliseconds: 200),
curve: Curves.easeIn,
));
/// [return] a [Column] of [TextField]s here...
}
Every time the keyboard kicks in or disappears, the Flutter framework will automatically call the build() method for u. U can try to place a breakpoint in the IDE to figure out this behavior yourself.
Future.delayed() will first immediately return a pending Future that will complete successfully after 400 milliseconds. Whenever the Dart runtime see a Future, it will enter a non-blocking I/O time (= inactive time = async time = pending time, meaning that the CPU is idle waiting for something to complete). While Dart runtime is waiting for this Future to complete, it will proceed to the lines of code below to build a column of text fields. And when the Future is complete, it will immediately jump back to the line of code of this Future and execute .then() method.
More about asynchronous programming from this simple visualization about non-blocking I/O and from the Flutter team.
Flutter does not have such thing by default.
Add your TextField in a ListView.
create ScrollController and assign it to the ListView's controller.
When you select the TextField, scroll the ListView using:
controller.jumpTo(value);
or if you wish to to have scrolling animation:
controller.animateTo(offset, duration: null, curve: null);
EDIT: Of course the duration and curve won't be null. I just copied and pasted it here.
Thank you all for the helpful answers #user2785693 pointed in the right direction.
I found complete working solution here:
here
Issue with just using scroll or focusNode.listner is, it was working only if I focus on textbox for the first time, but if I minimize the keyboard and again click on same text box which already had focus, the listner callback was not firing, so the auto scroll code was not running. Solution was to add "WidgetsBindingObserver" to state class and override "didChangeMetrics" function.
Hope this helps others to make Flutter forms more user friendly.
This is an attempt to provide a complete answer which combines information on how to detect the focus from this StackOverflow post with information on how to scroll from Arnold Parge.
I have only been using Flutter for a couple days so this might not be the best example of how to create a page or the input widget.
The link to the gist provided in the other post also looks like a more robust solution but I haven't tried it yet. The code below definitely works in my small test project.
import 'package:flutter/material.dart';
class MyPage extends StatefulWidget {
#override createState() => new MyPageState();
}
class MyPageState extends State<MyPage> {
ScrollController _scroll;
FocusNode _focus = new FocusNode();
#override void initState() {
super.initState();
_scroll = new ScrollController();
_focus.addListener(() {
_scroll.jumpTo(-1.0);
});
}
#override Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('Some Page Title'),
),
body: new DropdownButtonHideUnderline(
child: new SafeArea(
top: false,
bottom: false,
child: new ListView(
controller: _scroll,
padding: const EdgeInsets.all(16.0),
children: <Widget>[
// ... several other input widgets which force the TextField lower down the page ...
new TextField(
decoration: const InputDecoration(labelText: 'The label'),
focusNode: _focus,
),
],
),
),
),
);
}
}

Flutter: Use Navigator with TabBar and TabBarView

I'm trying to understand the class Navigator.
A Navigator Route seem to always return a new widget but what if I want to manage the TabBar and TabBarView so that each Tab when tapped or swipe on, will be pushed to the Navigator stack, I don't find a what to do that.
On a more general case, can I react to a route change without creating a new widget but instead taking another action like scrolling to a specific item in a listView?
I've tried recreating the entire app structure every time but doing this way I don't have the nice default animation and, also, doesn't seem a good approach to me.
You can use WillPopScope widget and its onWillPop to catch the back button pressure and handle it yourself. Find more info here https://docs.flutter.io/flutter/widgets/WillPopScope-class.html
On a more general case, can I react to a route change without creating
a new widget but instead taking another action like scrolling to a
specific item in a listView?
This looks more specific rather than general. However, you do need to set a ScrollController in your ListView and let it scroll the list for you to the desired point. A simple example function returning to top:
class MyFancyClass extends StatelessWidget{
...
ScrollController _scrollController;
...
#override
Widget build(BuildContext Context){
return ....
new ListView(
...
controller: _scrollController,
...
}
void _toTop() {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 500),
curve: Curves.ease,
);
}
Check https://docs.flutter.io/flutter/widgets/ScrollController-class.html for more details and behaviors. In case you want the back button to bring you to the top:
Widget build(BuildContext context) {
return new WillPopScope(
onWillPop: _onTop,
child:
...
),
);
}
Concerning the tab behavior I suggest you to read https://docs.flutter.io/flutter/material/TabController-class.html to better understand how to implement what you have in your mind.