How to manage multiple ScrollView widgets using one useScrollController() hook? - flutter

Flutter documentation for ScrollController has this paragraph:
Scroll controllers are typically stored as member variables in State objects and are reused in each State.build. A single scroll controller can be used to control multiple scrollable widgets, but some operations, such as reading the scroll offset, require the controller to be used with a single scrollable widget.
Does this mean that we cannot pass the same ScrollController to different ScrollView widgets to read ScrollController.offset?
What I'm trying to accomplish is this:
There are two screens. Each screen has a ListView.builder() widget.
Through parameters I pass from screen 1 to screen 2 an object ScrollController and apply it to ListView.
I use scrolling and the offset value changes, but as soon as I move/return to another screen, the offset is knocked down to 0.0 and I see the beginning of the list.
The same ScrollController object is used all the time (hashcode is the same)
How can we use one ScrollController object for different ScrollView widgets, so that the offset is not knocked down when moving from screen to screen?
This problem can be solved a bit if, when switching to another screen, we create a new ScrollController object with initialScrollOffset = oldScrollController.offset and pass it to ScrollView.
Update:
I don't seem to understand how to use flutter_hooks. I created a simple example showing that if we use separate widgets and specify ScrollController as a parameter, the scroll is reset to position 0.0.
Reference for an example:
https://dartpad.dev/?id=d31f4714ce95869716c18b911fee80c1
How do we overcome this?

For now, the best solution I can offer is to pass final ValueNotifier<double> offsetState; instead of final ScrollController controller; as a widget parameter.
Then, in each widget we create a ScrollController. By listening to it via the useListenableSelector hook we change the offsetState.
To avoid unnecessary rebuilding, we use the useValueNotifier hook.
A complete example looks like this:
void main() => runApp(
const MaterialApp(
debugShowCheckedModeBanner: false,
home: MyApp(),
),
);
class MyApp extends HookWidget {
const MyApp();
#override
Widget build(BuildContext context) {
print('#build $MyApp');
final isPrimaries = useState(true);
final offsetState = useValueNotifier(0.0);
return Scaffold(
appBar: AppBar(
title: Text(isPrimaries.value
? 'Colors.primaries List'
: 'Colors.accents List'),
actions: [
IconButton(
onPressed: () => isPrimaries.value = !isPrimaries.value,
icon: const Icon(Icons.ac_unit_sharp),
)
],
),
body: isPrimaries.value
? ListPrimaries(offsetState: offsetState)
: ListAccents(offsetState: offsetState),
);
}
}
class ListAccents extends HookConsumerWidget {
const ListAccents({
Key? key,
required this.offsetState,
}) : super(key: key);
final ValueNotifier<double> offsetState;
#override
Widget build(BuildContext context, WidgetRef ref) {
print('#build $ListAccents');
final controller =
useScrollController(initialScrollOffset: offsetState.value);
useListenableSelector(controller, () {
print(controller.positions);
if (controller.hasClients) {
offsetState.value = controller.offset;
}
return null;
});
return ListView(
primary: false,
controller: controller,
children: Colors.accents
.map((color) => Container(color: color, height: 100))
.toList(),
);
}
}
class ListPrimaries extends HookConsumerWidget {
const ListPrimaries({
Key? key,
required this.offsetState,
}) : super(key: key);
final ValueNotifier<double> offsetState;
#override
Widget build(BuildContext context, WidgetRef ref) {
print('#build $ListPrimaries');
final controller =
useScrollController(initialScrollOffset: offsetState.value);
useListenableSelector(controller, () {
print(controller.positions);
if (controller.hasClients) {
offsetState.value = controller.offset;
}
return null;
});
return ListView(
primary: false,
controller: controller,
children: Colors.primaries
.map((color) => Container(color: color, height: 100))
.toList(),
);
}
}
Another idea was to use useEffect hook and give it a function to save the last value at the moment of dispose():
useEffect(() {
return () {
offsetState.value = controller.offset;
};
}, const []);
But the problem is that at this point, we no longer have clients.
Bonus:
If our task is to synchronize the scroll of the ListView, another useListenableSelector hook added to each of the widgets solves this problem. Remind that we cannot use the same `ScrollController' for two or more lists at the same time.
useListenableSelector(offsetState, () {
if (controller.hasClients) {
// if the contents of the ListView are of different lengths, then do nothing
if (controller.position.maxScrollExtent < offsetState.value) {
return;
}
controller.jumpTo(offsetState.value);
}
});

Related

Is it possible to share and update one screen's reactive value in another screen without Provider?

So I have this block of code in a widget that navigates to another screen:
screen_one.dart
class ScreenOne extends StatefulWidget {
const ScreenOne({ super.key });
#override
State<ScreenOne> createState() => _ScreenOneState();
}
class _ScreenOneState extends State<ScreenOne> {
List<String> state = [''];
#override
Widget build(BuildContext context) {
return Column(
MaterialButton(
onPressed: () => Navigator.pushNamed(context, '/screen-two'),
child: Text('Click here.')
),
Text(state[0]),
);
}
}
screen_two.dart
class ScreenTwo extends StatelessWidget {
const ScreenTwo({ super.key });
#override
Widget build(BuildContext context) {
return Container();
}
}
Basically I need to pass the state variable from ScreenOne to ScreenTwo and then update it there (in ScreenTwo)
ScreenTwo needs to display the same thing as ScreenOne and add() a new item to the state list when some button is clicked which should show on both the screens.
Its just one simple List so I am trying to avoid using provider.
Is it possible to do though?
I'm currently just passing it through the Navigator:
screen_one.dart
Navigator.pushNamed(
context,
'/post-info',
arguments: state,
),
screen_two.dart
Widget build(BuildContext context) {
final List<String> post = ModalRoute.of(context)!.settings.arguments as List<String>;
// ...
}
first I want to recommend you when things go bigger and more complex, it's better to use a state management approach, However since you did say that you have only one List you can simply use a ValueNotifier, with ValueListenableBuilder:
// this should be outside widget classes, maybe in a custom-made class or just in a global scope.
ValueNotifier stateNotifier = ValueNotifier([""]);
now in the places you want to use that state, you can use ValueListenableWidget like this:
ValueListenableBuilder(
valueListenable: stateNotifier,
builder: (context, value, child) {
return Column(
children: [
Text('${state[0]}'),
MaterialButton(
onPressed: () {
Navigator.pushNamed(context, '/screen-two'),
},
child: Text('click'),
),
],
);
},
);
}
}
and any other place where you want to see that state get updates, you need to use ValueListenableWidget.
Now, for executing a method like add() on the List and notify the widgets, you need to assign a new value for it like this:
void addInTheList(String elem) {
List current = stateNotifier.value;
current.add(elem);
// this exactly what will be responsible for updating.
stateNotifier.value = current;
}
now, you can just call addInTheList and expect it to update in all of them:
addInTheList("Example");

Flutter: Is it possible to know if you're currently off stage?

I have a number of pages in my app wrapped in Offstage widgets. Each page makes use of the provider package to render based on state updates (e.g. the user does something, we make a network call and display the result).
As the pages are wrapped in Offstage widgets, the build() methods (and subsequent network calls) are called even if it's not the current page.
Is there a way inside the build() method to know if the widget is currently off stage (and if so, skip any expensive logic)?
I'm assuming I can work something with global state etc, but I was wondering if there was anything built-in in relation to the Offstage widget itself, similar to mounted
You can try finding the parent OffStage widget and see if the offstage property is true or false
#override
Widget build(BuildContext context) {
final offstageParent = context.findAncestorWidgetOfExactType<Offstage>();
if (offstageParent != null && offstageParent.offstage == false) {
// widget is currently offstage.
print('offstaged child');
} else {
// widget is not offstage
print('non-offstaged child');
}
return const Text('Example Widget');
}
I made a custom-made mechanism for the goal you wanna achieve:
First, I am declaring a new Map<String, bool> in a separate file alone that will hold the offStage bool value with the key of each class widget.
Map<String, bool> offStageMap = {};
then in the implementation of the StatefulWidget where the offstage widget is in:
class ExampleWidget extends StatefulWidget {
ExampleWidget({super.key}) {
widgetMapKey = runtimeType.toString();
}
late final String widgetMapKey;
#override
State<ExampleWidget> createState() => _ExampleWidgetState();
}
class _ExampleWidgetState extends State<ExampleWidget> {
final bool defaultIsOffStaged = false;
bool? localStateIsOffStages;
#override
void initState() {
offStageMap[widget.widgetMapKey] ??= defaultIsOffStaged;
super.initState();
}
#override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
bool previousIsOffStaged = offStageMap[widget.widgetMapKey]!;
setState(() {
localStateIsOffStages =
offStageMap[widget.widgetMapKey] = !previousIsOffStaged;
});
},
child: Offstage(
offstage: localStateIsOffStages ?? offStageMap[widget.widgetMapKey]!,
child: Container(),
),
);
}
} },
child: Offstage(
offstage: localStateIsOffStages ?? offStageMap[widget.widgetMapKey]!,
child: Container(),
),
);
}
}
let me explain what this is about.
first I declared a defaultIsOffStaged where it should be the initial offStage value when nothing is saved in that map.
when that widget is inserted in the widget tree (initState() called), the widget.widgetMapKey of the ExampleWidget widget will be saved in that map with the value of the default one which is defaultIsOffStaged.
offStageMap[widget.widgetMapKey] ??= defaultIsOffStaged;
in the offstage property o the OffStage widget, in this line:
offstage: localStateIsOffStages ?? offStageMap[widget.widgetMapKey]!,
the nullable localStateIsOffStages will be null for the first time since it has no value yet, so offStageMap[widget.widgetMapKey]! which equals to defaultIsOffStaged will be the bool value of offstage.
until now what we have, is a map containing the key that belongs only to the ExampleWidget which is its widget.widgetMapKey with its offStage value, right?
now from all places in your app, you can get the offStage value of that widget with its widgetMapKey like this:
print(offStageMap[ExampleWidget().widgetMapKey]); // true
now let's say you want to change the offstage property of that widget, in my code I used a simple example of GestureDetector, so when we tap in the Text("toggle offstage") area, it toggles offStage, here is what happens:
we got the existing value in the map:
bool previousIsOffStaged = offStageMap[widget.widgetMapKey]!;
then assign the opposite of it, to that widget key in the map, and the localStateIsOffStages bool variable which was nullable, now it has a value.
and as normal so the state updates I wrapped it in a SetState(() {})
now the widget's offstage will be toggled, and every time the widget key in the map will be updated with that new value.
the localStateIsOffStages I declared just to hold the local state when this is happening while the StatefulWidget state updates.
after the StatefulWidget is disposed of (when you pop the route as an example) and open that route again, the initState() will execute but since we have now an entry in the map, it's not null so nothing will happen inside initState().
the localStateIsOffStages will be null, so the offStage property of the Offstage widget will be the value from the map, which is the previous value before the widget is disposed.
that's it, from other places you can check for the offstage value of that specific widget like this:
print(offStageMap[ExampleWidget().widgetMapKey])
you can do it for all your widget pages, so you will have a map containing the offStage values of them all.
I take it one step up, and made those methods that I guess they will help:
this will return a List with the pages where the value is true.
List<String> offstagedPages() {
List<String> isOffStagedPages = [];
offStageMap.forEach((runtimeType, isOffStaged) {
if (isOffStaged) {
isOffStagedPages.add(runtimeType);
}
});
return isOffStagedPages;
}
this will return a true if a page is off staged and false if not:
bool isPageWidgetOffStaged(String runtimeType) {
if (offStageMap.containsKey(runtimeType)) {
return offStageMap[runtimeType]!;
}
return false;
}
Hope this helps a little.
Maybe it's not applicable to you, but you might be able to solve it by simply not using Offstage. Consider this app:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatefulWidget {
MyApp({super.key});
#override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool showFirst = true;
void switchPage() {
setState(() {
showFirst = !showFirst;
});
}
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Stack(children: [
Offstage(offstage: !showFirst,child: A("first", switchPage)),
Offstage(offstage: showFirst,child: A("second", switchPage)),
]))));
}
}
class A extends StatelessWidget {
final String t;
final Function onTap;
const A(this.t, this.onTap, {Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
print('$t is building');
return TextButton(onPressed: ()=> onTap(), child: Text(t));
}
}
You will notice by the prints that both pages are build. But if you rewrite it like this without Offstage, only the visible one is build:
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Stack(children: [
if (showFirst) A("first", switchPage),
if (!showFirst) A("second", switchPage),
]))));
}
If you want to just keep state alive your pages , you can use https://api.flutter.dev/flutter/widgets/AutomaticKeepAliveClientMixin-mixin.html , you may check this blog for example usage, https://medium.com/manabie/flutter-simple-cheatsheet-4370a68f98b3
If you are using Navigator, you can just extends NavigatorObserver. Then you will get didpush and didpop, use state to manage elementlifecycle, you will get page onPause and onResume fun.

Pass parent state to generic child widget

I'm building a flutter app, and I have built a customized AppBar widget for it. This appbar has a SearchBar widget, which calls whatever callback is passed to it onChange. Now, there are multiple screens that use this SearchBar, and each of them will do something different with the user input. But I've noticed that on each of the screens that use the appbar, I'd have to use a state to control the SearchBar inputted text. So, I'm trying to not have to create the state for every screen, and have a Widget that wraps my screens, and controls the input in it's state, and passes it's state down to the child I provide to this apps. This would be similar to React's Higher Order Components, which wrap another component and can pass props to it.
This seems to me like a good design pattern, but I don't know how to implement it. Since the child widget that I would pass to this second order component that would wrap my screens, won't be getting any info from it, the child is simply passed as a widget (note: the code is also doing other stuff, working as a general wraper to replace similar repetitive code in all of my screens):
class CustomScaffold extends StatefulWidget {
final ScreenInfo appBarInfo;
final Builder child;
final Widget bottomBarApp;
final Widget appBar;
final EdgeInsetsGeometry padding;
const CustomScaffold({
#required this.child,
Key key,
this.bottomBarApp,
this.appBar,
this.padding,
this.appBarInfo,
}) : super(key: key);
#override
_CustomScaffoldState createState() => _CustomScaffoldState();
}
class _CustomScaffoldState extends State<CustomScaffold> {
String searchTerm = '';
#override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
widget.appBar ??
GradientAppBar(
title: widget.appBarInfo.label,
searchBar: SearchBar(
callback: (String input) {
setState(() {
searchTerm = input;
});
},
placeholder: widget.appBarInfo.searchPlaceholder,
),
),
Padding(
padding: widget.padding,
child: widget.child,
),
],
),
),
bottomNavigationBar: widget.bottomBarApp ?? CustomBottomBarNavigator(),
);
}
}
I'm thinking that I could passa builder instead of a widget as the child, but I'm not sure how this would work. Also, I'm still learning bloc in general, so I'm not sure if it would be a good idea to use bloc for this. I'm guessing bloc's purpose is a little different, and would complicate this specific pattern.
Does this idea make sense? What would be the best way to implement it?
Thanks in advance.

Access a parent class variable in its child in Flutter?

I am trying to use a custom statefull PageWrapper widget to wrap all my pages. The idea is to make it return a Scaffold and use the same menu drawer and bottom navigation bar, and call the appropriate page as page parameter.
My bottomNavigationBar is working well and I am setting the correct selectedIndex, but I can't find a way to access it in the child page (that is in another file), since I don't know how to access the parent's selectedIndex and display the appropriate widget from my page's list.
import 'package:flutter/material.dart';
class PageWrapper extends StatefulWidget {
final Widget page;
final AppBar appBar;
final BottomNavigationBar bottomNav;
final Color bckColor;
PageWrapper({#required this.page, this.appBar, this.bckColor, this.bottomNav});
#override
_PageWrapperState createState() => _PageWrapperState();
}
class _PageWrapperState extends State<PageWrapper> {
int _selectedIndex;
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
#override
void initState() {
super.initState();
_selectedIndex = 0;
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: widget.appBar,
backgroundColor: widget.bckColor,
bottomNavigationBar: CustomBottomNavigation(selectedIndex: _selectedIndex, onItemTapped: _onItemTapped),
body: widget.page,
drawer: Drawer(...),
);
}
}
Named roots in my main.dart:
home: PageWrapper(page: HomeScreen()),
routes: {
'form': (context) => PageWrapper(page: RoomService()),
},
I would like to access that bottom navigation bar's current index somehow in my HomeScreen and RoomService screen. Is there a way to do it?
You can solve that by using a State Management tool like Provider or Bloc. To keep things simple, lets use Provider to do it.
Wrap MaterialApp with a ChangeNotifierProvider in your main.dart.
return MultiProvider(
providers: [
ChangeNotifierProvider<IndexModel>(
create: (context) => IndexModel()),
],
child: MaterialApp(...)
);
Create a model that will hold your index value:
Also, you have to override the getter and setter of index in order to call notifyListeners after its value is set. Here is an example:
class IndexModel extends ChangeNotifier {
int _index;
get index => _index;
set index(int index) {
_index = index;
notifyListeners(); //Notifies its listeners that the value has changed
}
}
Here is how you can display your data according to its index (Ideally, you should use Selector instead of Consumer so that the widget only rebuilds if the value it is listening to, changes):
#override
Widget build(BuildContext context) {
//other widgets
Selector<IndexModel, String>(
selector: (_, model) => model.index,
builder: (_, i, __) {
switch(i){
//do your returning here based on the index
}
},
);
}
)
}
Extra note. Here is how you can access the values of ImageModel in your UI:
final model=Provider.of<IndexModel>(context,listen:false);
int index =model.index; //get index value
model.index=index; //set your index value
You have to pass listen:false when you aren't listening for changes. This is needed when you are accessing it in initState or in onPressed.

Flutter bottomNavigator with indexed stack

Question regarding navigating between tabs using indexed stack to display relevant page. I'm doing this in order to keep scroll/state of pages. This works fine. I can change the current page displayed by clicking tab - and can also navigate inside each page (each page is wrapped with it's own Navigator). This is the code for rendering the pages.
Widget build(BuildContext context) {
return IndexedStack(
index: widget.selectedIndex,
children: List.generate(widget._size, (index) {
return _buildNavigator(index);
}));
}
Mu problem is that IndexedStack builds all pages at once. In some of my pages I want to load data from an API, I want to do it when the widget first time built and only if the page is currently visible. Is there a way to do so? in my current implementation all widgets build at once and so all my API calls are called even for the pages that are not currently painted.
Not sure if i'm missing something here, or there is a better way to implement bottom navigation bar. BTW i'm also using Provider for state management.
#tsahnar yea i have also faced same issue related with api call indexed widget render all widgets provided it to its children at once so when individual pages are independently fetching data from api then here comes the problem
try this :
create list of widgets which navigates through your navbar (each widget with key constructor where define PageStorageKey(<key>) for each widgets)
var widgetList = <Widget>[
Page01(key:PageStorageKey(<key>)),
Page02(key:PageStorageKey(<key>))
];
then create PageStorageBucket() which stores your widgets state and provides it in future whenever we need it in a lifetime of app even the widget gets disposed from the tree
final _bucket = PageStorageBucket();
then
var currentIndex = 0;
then in your main base page where the bottom navbar exists in your body instead of IndexedStack use body:PageStorage(bucket: _bucket,child:widgetsList[currentIndex])
and create bottomnavbar in that main base page and then onNavbar icon tab manage index page impherial state by setState((){}) the current state to the currentIndex
it should fix your problem tho its too late after a year
I encountered the same problem. My solution was to save a list of the loaded tabs and then use that to build the list of IndexedStack children inside the Widget build(BuildContext context) method. Then in the onTap method of the BottomNavigationBar, I called setState() to update the list of loaded tabs as well as the current index variable. See below:
class Index extends StatefulWidget {
const Index({Key? key}) : super(key: key);
#override
_IndexState createState() => _IndexState();
}
class _IndexState extends State<Index> {
int _currentIndex = 0;
List loadedPages = [0,];
#override
Widget build(BuildContext context) {
var screens = [
const FirstTab(),
loadedPages.contains(1) ? const SecondTab() : Container(),
loadedPages.contains(2) ? const ThirdTab() : Container(),
loadedPages.contains(3) ? const FourthTab() : Container(),
];
return Scaffold(
appBar: AppBar(
// AppBar
),
body: IndexedStack(
index: _currentIndex,
children: screens,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
var pages = loadedPages;
if (!pages.contains(index)) {
pages.add(index);
}
setState(() {
_currentIndex = index;
loadedPages = pages;
});
},
items: const [
// items
],
),
);
}
}
Now, the API calls on the second, third, and fourth tabs don't call until navigated to.
do you found a solution?
I found the same problem as you and I tried this workaround (i didn't found any issues with it yet)
The idea is to make a new widget to control the visibility state of the widgets that made the api call and build it when it became visible.
In your IndexedStack wrap your _buildNavigator with a widget like this:
class BaseTabPage extends StatefulWidget {
final bool isVisible;
final Widget child;
BaseTabPage({Key key, this.child, this.isVisible});
#override
State<StatefulWidget> createState() => _BaseTabPageState();
}
/*
This state is to prevent tab pages creation before show them. It'll only add the
child widget to the widget tree when isVisible is true at least one time
i.e. if the child widget makes an api call, it'll only do when isVisible is true
for the first time
*/
class _BaseTabPageState extends State<BaseTabPage> {
bool alreadyShowed = false;
#override
Widget build(BuildContext context) {
alreadyShowed = widget.isVisible ? true : alreadyShowed;
return alreadyShowed ? widget.child : Container();
}
}
For example in my code i have something like this to build the navigators for each tab, where _selectedIndex is the selected position of the BottomNavigationBar and tabPosition is the position of that page in the BottomNavigationBar
Widget _buildTabPage(int tabPosition) {
final visibility = _selectedIndex == tabPosition;
return BaseTabPage(
isVisible: visibility,
child: _buildNavigator(tabPosition),
);
}
With this i have the logic of the api call entirely in the children widgets and the bottom navigation knows nothing about them.
Let me know if you see something wrong with it since i'm kind of new with flutter.
Use a PageView instead of an IndexedStack
PageView(
physics: const NeverScrollableScrollPhysics(),
controller: _pageController,
children: const [
Page1(),
Page2(),
Page3(),
],
),
you can switch pages using the pageController
_pageController.jumpToPage(0);
Flutter will build all widgets inside a stack so if your pages do an API call inside initState, then it will be triggered on build.
What you can do is have a separate function for the API call. Then call the function from the navigator or from the state management.
I hope this helps give you an idea on how to implement this.