I have an app that uses a PageView on its main page. Today, I got assigned to insert a TabBarView in one of these pages. The problem is that when I scroll the between the tabs when in the last tab, scrolling to the left won't scroll the PageView.
I need a way to make the scroll of page view scroll when at the start or end of the tabbarview.
I found a question with the inverted problem: flutter PageView inside TabBarView: scrolling to next tab at the end of page
However, the method stated there is not suitable to my issue.
I made a minimal example:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) => MaterialApp(
title: 'TabBarView inside PageView',
home: MyHomePage(),
);
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final PageController _pageController = PageController();
#override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text('TabBarView inside PageView'),
),
body: PageView(
controller: _pageController,
children: <Widget>[
Container(color: Colors.red),
GreenShades(),
Container(color: Colors.yellow),
],
),
);
}
class GreenShades extends StatefulWidget {
#override
_GreenShadesState createState() => _GreenShadesState();
}
class _GreenShadesState extends State<GreenShades>
with SingleTickerProviderStateMixin {
TabController _tabController;
#override
void initState() {
this._tabController = TabController(length: 3, vsync: this);
super.initState();
}
#override
Widget build(BuildContext context) => Column(
children: <Widget>[
TabBar(
labelColor: Colors.green,
indicatorColor: Colors.green,
controller: _tabController,
tabs: <Tab>[
const Tab(text: "Dark"),
const Tab(text: "Normal"),
const Tab(text: "Light"),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: <Widget>[
Container(color: Colors.green[800]),
Container(color: Colors.green),
Container(color: Colors.green[200]),
],
),
)
],
);
#override
void dispose() {
_tabController.dispose();
super.dispose();
}
}
Note that, in this MRE, it's possible to reach the 3rd page if you drag the TabBar, but not if you drag the TabBarView.
How may I achieve this behavior?
Edit:
As stated by #Fethi, there's a similar question:
Is it possible to swipe from an TabBarView content area to an adjacent PageView page?
However, the question was not answered satisfactorily, as the solution given does not really "blend" the scroll, although the behavior is similar to what was described. It doesn't scroll naturally.
This is possible by using the PageController.postion attribute's drag method, which internally drags the ScrollPosition of the screen. This way, user can intuitively drag the pages like drag halfway and then leave or continue fully.
The idea is inspired from the other post to use the OverScrollNotification but add rather more step to continue intuitive dragging.
Collect the DragstartDetail when user starts scrolling.
Listen for OverScrollNotification and start the draging and at the same time update the drag using the drag.update with the DragUpdateDetails from OverscrollNotification method.
On ScrollEndNotification cancel the the drag.
To keep the idea simple I am pasting only build method of the Tabs page.
A fully working example is available in this dart pad.
#override
Widget build(BuildContext context) {
// Local dragStartDetail.
DragStartDetails dragStartDetails;
// Current drag instance - should be instantiated on overscroll and updated alongside.
Drag drag;
return Column(
children: <Widget>[
TabBar(
labelColor: Colors.green,
indicatorColor: Colors.green,
controller: _tabController,
tabs: <Tab>[
const Tab(text: "Dark"),
const Tab(text: "Normal"),
const Tab(text: "Light"),
],
),
Expanded(
child: NotificationListener(
onNotification: (notification) {
if (notification is ScrollStartNotification) {
dragStartDetails = notification.dragDetails;
}
if (notification is OverscrollNotification) {
drag = _pageController.position.drag(dragStartDetails, () {});
drag.update(notification.dragDetails);
}
if (notification is ScrollEndNotification) {
drag?.cancel();
}
return true;
},
child: TabBarView(
controller: _tabController,
children: <Widget>[
Container(color: Colors.green[800]),
Container(color: Colors.green),
Container(color: Colors.green[200]),
],
),
),
),
],
);
}
Old Answer
The above might not handle some edge cases. If you need more control below code provides the same result but you can handle UserScrollNotification. I am pasting this because, it might be useful for others who would like to know which direction the use is scrolling w.r.t the Axis of the ScrollView.
if (notification is ScrollStartNotification) {
dragStartDetails = notification.dragDetails;
}
if (notification is UserScrollNotification &&
notification.direction == ScrollDirection.forward &&
!_tabController.indexIsChanging &&
dragStartDetails != null &&
_tabController.index == 0) {
_pageController.position.drag(dragStartDetails, () {});
}
// Simialrly Handle the last tab.
if (notification is UserScrollNotification &&
notification.direction == ScrollDirection.reverse &&
!_tabController.indexIsChanging &&
dragStartDetails != null &&
_tabController.index == _tabController.length - 1) {
_pageController.position.drag(dragStartDetails, () {});
}
so you want to scroll the page view to the left when you reach the end of tabs and the same goes to scrolling to the right when on the first tab, what i have been thinking about is manually swipe the page view when in those cases as follow:
index value should the index of page that comes before the tab bar page and after it.
pageController.animateToPage(index,
duration: Duration(milliseconds: 500), curve: Curves.ease);
here is a complete code of what you are looking for, hopefully this helps!
I have a different approach using Listener Widget and TabView physics as show below:
//PageView Widget
#override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
children: [
Widge1()
TabBarWidget(),
Widget2()
]
)
)
}
//TabBar Widget
final _physycsNotifier = ValueNotifier<bool>(false);
....
....
#override
Widget build(BuildContext context) {
return Column(
children: [
TabBar(
controller: _tabController,
//... other properties
)
Expanded(
child: Listener(
onPointerMove: (event) {
final offset = event.delta.dx;
final index = _tabController.index;
//Check if we are in the first or last page of TabView and the notifier is false
if(((offset > 0 && index == 0) || (offset < 0 && index == _categories.length - 1)) && !_physycsNotifier.value){
_physycsNotifier.value = true;
}
},
onPointerUp: (_) => _physycsNotifier.value = false;
child: ValueListenableBuilder<bool>(
valueListenable: _physycsNotifier,
builder: (_, value, __) {
return TabBarView(
controller: _tabController,
physics: value ? NeverScrollableScrollPhysics() : null,
children: List.generate(_categories.length, (index) {
return _CategoryTab(index: index);
})
);
},
),
)
)
]
)
}
this works fine if you set default physics for PageView and TabView (it means null) if you set other physisc like BouncingScrollPhsysisc there will be some bugs, but i think this is good workaround.
Related
I am using a TabBarView in my app like this:
#override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
TabBarView(
controller: _controller,
physics: const NeverScrollableScrollPhysics(),
children: const [
WishlistsView(),
FriendsView(),
EventsView(),
InboxView(),
ProfileView(),
],
),
Align(
alignment: Alignment.bottomCenter,
child: BottomNavBar(
initialIndex: widget.navBarOption.index,
onPageChanged: (index) => _tap(context, index),
),
),
],
),
);
}
void _tap(BuildContext context, int index) => context.go(
'/home/${BottomNavBarOption.values[index].name}',
);
Now the problem is that when I go on another page the first time there is a very short white screen before the page is actually displayed.
Here is a ScreenVideo for a better understanding. This happens on both Web and iOS.
Why is that happening? Can I avoid that? As you can see the views are not very heavy.
All they have is basically a SVGPicture.asset :
class _WishlistsViewState extends State<WishlistsView>
with AutomaticKeepAliveClientMixin {
#override
bool get wantKeepAlive => true;
#override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body: Stack(
children: const [
BackgroundImage(option: BackgroundImageOption.wishlists),
],
),
);
}
}
It is probably taking time to load svg on the first go and caching it for showing it later.. You can probably try OffStage Widget which should load the UI and just not display it.
class _WishlistsViewState extends State<WishlistsView>
with AutomaticKeepAliveClientMixin {
#override
bool get wantKeepAlive => true;
#override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body: Offstage(
offStage: false,
child: Stack(
children: const [
BackgroundImage(option: BackgroundImageOption.wishlists),
],
),
),
);
}
}
as #Kaushik Chandru correctly pointed out: The SVG are the problem. They have to load first. To solve that issue, I used this really useful function precachePicture right before I actually call runApp:
for (BackgroundImageOption imageOption in BackgroundImageOption.values) {
await precachePicture(
ExactAssetPicture(
SvgPicture.svgStringDecoderBuilder,
imageOption.assetPath,
),
null,
);
}
I have CustomScrollView with a SliverGrid with different widget types inside. I have a widget that appears after the stream content ends and other content will load. When this widget appears, I want to shortly pin it on the screen and disable the scrolling for 3 seconds.
To simplify it, I skipped the sliver delegates and summarizes my widget tree like this:
CustomScrollView{
controller: scrollController,
slivers: [
SliverAppBar(),
SliverStreamGrid(
children: [
ProductTile(),
ProductTile(),
ProductTile(),
// End of Available Products
EndOfProductsInfoWidget(), // should be pinned on screen for 3 seconds
SomeOtherProductTile(),
SomeOtherProductTile(),
SomeOtherProductTile(),
]
)
]
}
I am using visibility_detector to detect visibility of the widget and SliverPinnedHeader from sliver_tools package.
The issue lies when our widget is visible I am using a short delay and then disabling scroll event for 3 seconds, you can use global key for this and have more precious output.
CustomScrollView's physics:NeverScrollableScrollPhysics() used to disable scroll event.
class SliverPinTHEx extends StatefulWidget {
const SliverPinTHEx({Key? key}) : super(key: key);
#override
State<SliverPinTHEx> createState() => _SliverPinTHExState();
}
class _SliverPinTHExState extends State<SliverPinTHEx> {
bool? _showPinMessage;
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
physics: _showPinMessage == true
? const NeverScrollableScrollPhysics()
: null,
slivers: [
list(),
if (_showPinMessage != false)
SliverPinnedHeader(
child: VisibilityDetector(
onVisibilityChanged: (info) async {
if (info.visibleFraction == 1.0) {
debugPrint("disable scroll");
await Future.delayed(const Duration(seconds: 1));
setState(() {
_showPinMessage = true;
});
Future.delayed(const Duration(seconds: 3)).then((value) {
setState(() {
_showPinMessage = false;
});
});
}
},
key: const Key('my-widget-key'),
child: Container(
height: 70,
color: Colors.amber,
child: const Text("pinned widget"),
),
)),
list()
],
),
);
}
SliverList list() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return SizedBox(
height: 50,
child: Text(
index.toString(),
),
);
},
childCount: 55,
),
);
}
}
On press key 1, ListView adds 1 tile, on press key 2 ListView removes one tile, though after clicking with mouse outside of ListView or Text() widget, keyboard keys stop responding without any error being shown in terminal.
I thought, that maybe FocusNode was disposed after clicking outside of ListView, though, after testing, this seems not to be the case
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class OnTapWidgetIssue extends StatefulWidget {
OnTapWidgetIssue({Key? key}) : super(key: key);
String testOnTap = '';
int nOfList = 1;
#override
_OnTapWidgetIssueState createState() => _OnTapWidgetIssueState();
}
class _OnTapWidgetIssueState extends State<OnTapWidgetIssue> {
final FocusNode _focusNode = FocusNode();
#override
void dispose() {
_focusNode.dispose();
print('_focusNode.dispose()');
super.dispose();
}
void _handleKeyEvent(RawKeyEvent event) {
if (event is RawKeyDownEvent &&
event.data.logicalKey == LogicalKeyboardKey.digit1) {
widget.nOfList += 1;
setState(() {});
}
if (event is RawKeyDownEvent &&
event.data.logicalKey == LogicalKeyboardKey.digit2) {
if (widget.nOfList > 1) {
widget.nOfList--;
setState(() {});
} else {}
}
}
#override
Widget build(BuildContext context) {
return Scaffold(
drawer: MenuDrawer(),
appBar: AppBar(title: Text('OnTap-widget.Issue')),
body: RawKeyboardListener(
autofocus: true,
focusNode: _focusNode, // <-- more magic
onKey: _handleKeyEvent,
child: Column(children: [
Text(widget.testOnTap, style: TextStyle(fontSize: 52.0)),
Text('''
press 1 to add ListTile
press 2 to remove ListTile
'''),
Expanded(
child: Row(
children: [
Expanded(
flex: 2,
child: SizedBox(),
),
Expanded(
flex: 1,
// child: SizedBox(),
// // ),
child: ListView.builder(
itemCount: widget.nOfList,
// itemCount: widget.testOnTap.length,
itemBuilder: (_, i) {
return ListTile(
title: Text('$i'),
onTap: () {
widget.testOnTap = widget.testOnTap + i.toString();
setState(() {});
},
// Handle your onTap here.
);
},
),
),
Expanded(
flex: 2,
child: SizedBox(),
),
],
),
),
]),
),
);
}
}
Also Im getting error when clicking to go to new page in the app
Error: A FocusNode was used after being disposed.
Once you have called dispose() on a FocusNode, it can no longer be used.
at Object.throw_ [as throw] (http://localhost:49535/dart_sdk.js:5061:11)
at http://localhost:49535/packages/flutter/src/foundation/change_notifier.dart.lib.js:66:21
at focus_manager.FocusNode.new.[_debugAssertNotDisposed] (http://localhost:49535/packages/flutter/src/foundation/change_notifier.dart.lib.js:69:25)
at focus_manager.FocusNode.new.notifyListeners (http://localhost:49535/packages/flutter/src/foundation/change_notifier.dart.lib.js:131:41)
at focus_manager.FocusNode.new.[_notify] (http://localhost:49535/packages/flutter/src/widgets/widget_inspector.dart.lib.js:42893:12)
at focus_manager.FocusManager.new.[_applyFocusChange] (http://localhost:49535/packages/flutter/src/widgets/widget_inspector.dart.lib.js:43665:26)
at Object._microtaskLoop (http://localhost:49535/dart_sdk.js:38778:13)
at _startMicrotaskLoop (http://localhost:49535/dart_sdk.js:38784:13)
at http://localhost:49535/dart_sdk.js:34519:9
How ever, I don't get this error when selecting exercise page in drawer menu, only when going to this new page from home page. Exercise and Home pages are kinda similar, but still different in some aspects.
Thank
Technically, you are not adding the onTap to the ListView.builder, you're adding it to every single ListTile added by the builder. :)
Declare your two state variables:
String testOnTap = '';
int nOfList = 1;
inside the _OnTapWidgetIssueState class, not the OnTapWidgetIssue class. The convention is to name them _testOnTap and _nOfList respectively since they are private to the class.
And update the two variables INSIDE the setState call, not outside it.
I'm trying to implement pagination but I can't find any examples of how I should create the controller Listener function - or where I should put it. Please advise. Let me know if I should add more info too.
Currently, my listener function looks like this:
(within initState)
pagecontroller.addListener(() {
print(pagecontroller.page);
if (pagecontroller.page == _postslist.length-1) {
fetchMore();
}
});
What happens currently is that the function is only called once, and subsequently never called later on.
I don't know if this problem still exists (it's been six months since you've asked), but since this question still doesn't have an answer that is marked as correct I'll try.
If I understand correctly you want to load more items into your PageView once you've reached the last item of your PageView. You don't need a listener in your initState for that. You can just check if you've reached the last item in onPageChanged and then load more items.
It should work like this:
PageView.builder(
controller: _pageController,
itemCount: _items.length,
onPageChanged: (i) {
if (i == _items.length - 1) {
getMoreItems().then((value) {
setState(() {
_items= value;
});
});
}
},
)
I guess you are trying to listen to pageController to get the currentPage. If that's the case, you should fire an event using the PageController by using its methods (animateToPage, jumpToPage, nextPage, previousPage), so that it can evoke your listener.
I assume my page transitions are handled by the PageView.builder
You can find the PageView.builder description in the documentation like this:
This constructor is appropriate for page views with a large (or infinite) number of children because the builder is called only for those children that are actually visible.
So it supports you in building the screens efficiently in case of large number of pages. You'll still need to handle navigation between pages on your own.
The link I've included above has an example you can refer to in terms of PageController usage. I'll include it here for convenience:
class MyPageView extends StatefulWidget {
MyPageView({
Key key
}): super(key: key);
_MyPageViewState createState() => _MyPageViewState();
}
class _MyPageViewState extends State < MyPageView > {
PageController _pageController;
#override
void initState() {
super.initState();
_pageController = PageController();
}
#override
void dispose() {
_pageController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: PageView(
controller: _pageController,
children: [
Container(
color: Colors.red,
child: Center(
child: RaisedButton(
color: Colors.white,
onPressed: () {
if (_pageController.hasClients) {
_pageController.animateToPage(
1,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
},
child: Text('Next'),
),
),
),
Container(
color: Colors.blue,
child: Center(
child: RaisedButton(
color: Colors.white,
onPressed: () {
if (_pageController.hasClients) {
_pageController.animateToPage(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
}
},
child: Text('Previous'),
),
),
),
],
),
),
);
}
}
I have a DefaultTabController containing a TabBarView with a TabController, so far nothing special.
I have 1 tab which needs to make a request to the server as soon as it's initialized, let's call it the explore tab.
I'm making the request in didChangeDependencies if it wasn't already initialized (I have a boolean flag for initialized).
It's indeed making a request everytime the tab's widget is created, however there's one problem.
Let's say there are 3 tabs, while the explore tab is the second tab in between 1 and 3.
When going from tab 1 to tab 3, or from tab 3 to tab 1, it seems that the explore tab is created during the transition (even though it's not displayed at all), and a request to the server is consequently made.
I'd like to avoid that and only make that request in the explore tab's didChangeDependencies when the user is specifically going to the explore tab.
Any ideas?
Ok, so, you have 3 tabs
[tab1],[tab2],[tab3]
Where you would like to triger an event (request to the server) only when [tab2] was pressed.
To achieve this, create a tabController isolated to add an event listener in a StateFull widget, example:
TabController _tabController;
#override
void initState() {
super.initState();
_tabController = TabController(
length: 2,
vsync: this,
);
_tabController.addListener(() {
if (_tabController.indexIsChanging == false) {
if(_tabController.index == 1) {
//Trigger your request
}
}
});
}
Note: indexIsChanging == false is to ensure that tab already finished the job of change currentIndex of that controller, full example below:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
home: BodyWidget(),
);
}
}
class BodyWidget extends StatefulWidget {
#override
BodyWidgetState createState() {
return new BodyWidgetState();
}
}
class BodyWidgetState extends State<BodyWidget>
with SingleTickerProviderStateMixin {
TabController _tabController;
#override
void initState() {
super.initState();
_tabController = TabController(
length: 3,
vsync: this,
);
_tabController.addListener(() {
if (_tabController.indexIsChanging == false) {
if (_tabController.index == 1) {
//Trigger your request
print('Triger 2 selected, do the http call now.');
}
}
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('test'),
bottom: TabBar(
controller: _tabController,
tabs: <Widget>[
Tab(text: 'Tab 1'),
Tab(text: 'Tab 2'),
Tab(text: 'Tab 3'),
],
),
),
body: TabBarView(
controller: _tabController,
children: <Widget>[
Container(child: Center(child: Text('tab1'))),
Container(child: Center(child: Text('tab2'))),
Container(child: Center(child: Text('tab3'))),
],
),
);
}
}