Nested scrollbars issue: NestedScrollView, SliverAppBar, SliverPersistentHeader, TabBarView, and PageView - flutter

This issue has been haunting me for some time and even though I read countless web pages, I still cannot solve it. Maybe you're able to help out!
I got the following scenario:
A Flutter app has a PageView with just 3 pages.
First page has a simple GridView (vertical scrolling) and no problem there with nested scrolling.
Third page is a simple ListView with lists of items, no problem here also with scrolling.
The second page has a NestedScrollView, with two widgets for its headerSliverBuilder: SliverAppBar and SliverPersistentHeader. Nothing fancy here.
The body of the NestedScrollView contains a TabBarView which contains 3 tabs, and it's possible to swipe between them using a horizontal swipe -- the same swipe direction as the PageView which contains this page.
The body is where the scrolling problem occurs.
Swiping between the 3 tabs works like a charm. However, when the current tab is the first and you try to swipe further to the left (finger motion is from left-to-right), the first page (belonging to PageView) doesn't show. Conversely, when the current tab is the 3rd and you try to swipe further to the right (finger motion is from right-to-left), nothing happens.
If you do the same finger motion on the header contained in the second page, the pages turn fine (either to the first or third pages).
Here's the code inside the second page, would love to know why swiping motion inside the TabBarView isn't propagating to the container when tabs reach the edges:
Scaffold(
appBar: _generateAppBar(),
body: DefaultTabController(
length: 3,
child: NestedScrollView(
headerSliverBuilder: (_, __) => [
SliverAppBar(
backgroundColor: backgroundColor,
elevation: 0.0,
expandedHeight: 200.0,
floating: true,
pinned: false,
flexibleSpace: backgroundImageView,
),
SliverPersistentHeader(
floating: false,
delegate: _SliverAppBarDelegate(
TabBar(
labelColor: Theme.of(context).primaryColor,
unselectedLabelColor: Colors.black26,
indicatorWeight: 2.5,
tabs: const [
Text('Tab 1'),
Text('Tab 2'),
Text('Tab 3'),
],
),
),
pinned: true,
),
],
body: TabBarView(
children: [
Center(child: Text('Body 1')),
Center(child: Text('Body 2')),
Center(child: Text('Body 3')),
],
),
),
),
);
Auxiliary class:
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate(this._tabBar);
final TabBar _tabBar;
#override
double get minExtent => _tabBar.preferredSize.height;
#override
double get maxExtent => _tabBar.preferredSize.height;
#override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) =>
Container(child: _tabBar);
#override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) =>
false;
}
Any idea how to fix this? Thanks!

You should wrap the
NestedScrollView like
DefaultTabController(
length: tabs.length,
child: NestedScrollView())

Related

How to create custom header which hides and shows on scroll, like Facebook app

I'm trying to replicate a custom header, above a ListView, which hides and shows when the user scrolls the list. Very specifically, I want it to behave like the Facebook app... as soon as the user scrolls down, the header slides up out of view. Then, no matter how far down the list you are, when you scroll back up the header slides back into view immediately.
I've been playing with various Slivers, AnimatedContainers etc, but I can't get this exact behaviour.
SliverAppBar seems the closest, but it seems to have a predetermined structure, and I can't see a way to make it completely customizable.
SliverPersistentHeader and SliverToBoxAdapter both seem to remain fixed in place, and don't reappear when you scroll back up.
Any ideas on now to achieve this please?
I made something like this a while ago but only with a searchbar as the content of the SliverAppBar and because the SliverAppBar needs a predetermined size to be built I used a work-around like this.
class _ListScreenState extends State<ListScreen> {
final GlobalKey _flexibleSpaceBarKey = GlobalKey();
late Size sizeFlexibleSpaceBar;
bool _visible = true;
getSizeAndPosition() {
RenderBox _cardBox = _flexibleSpaceBarKey.currentContext!.findRenderObject() as RenderBox;
sizeFlexibleSpaceBar = _cardBox.size;
}
#override
void initState() {
super.initState();
WidgetsBinding.instance!.addPostFrameCallback((_) => getSizeAndPosition());
Future.delayed(Duration(microseconds: 1)).then((value) {
setState(() {
_visible = false;
});
});
}
Widget _copyFlexibleSpaceBar() {
return Visibility(
visible: _visible,
key: _flexibleSpaceBarKey,
child: _buildSearchbar(),
);
}
Widget _buildSearchbar() {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 12.0,
),
child: MyCustomSearchBar(),
);
}
#override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('Example-Title'),
elevation: 0.0,
),
body: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
_copyFlexibleSpaceBar(),
if (!_visible)
Expanded(
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) =>
<Widget>[
SliverAppBar(
pinned: true,
floating: true,
toolbarHeight: 0.0,
expandedHeight: sizeFlexibleSpaceBar.height,
elevation: 4.0,
flexibleSpace: FlexibleSpaceBar(
background: _buildSearchbar(),
),
),
],
body: Container() // Your content
),
),
],
),
),
);
}
}
(Not sure if it works because I copied it from my git-repo and changed/removed some stuff)
So what I did there was, to build my custom header and immediately make it invisible, thus I can get the actual size and can use it to build the SliverAppBar.
I have to add, this idea is not mine, I got it from another post which I can't find right now.
Hope it somehow helps you.

How to add Grids into TabbarView in Flutter?

So basically I have this widget:
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// ...
SliverToBoxAdapter(
child: TabBar(
controller: this._controller,
indicator: UnderlineTabIndicator(
borderSide: BorderSide(width: 1.0, color: Colors.red),
),
tabs: [
Tab(icon: Icon(CupertinoIcons.camera)),
Tab(icon: Icon(CupertinoIcons.photo)),
Tab(icon: Icon(CupertinoIcons.video_camera)),
],
),
),
SliverFillRemaining(
child: TabBarView(
controller: this._controller,
children: [
// Want Scrollable Grid here
// Want Scrollable Grid here
Center(
child: Text("Hello Reader🙂"),
),
],
),
),
// ...
],
),
);
}
}
I want to add a 2 scrollable grids as children in the TabBarView however when I use GridView.builder(...), there is an annoying gap at the top of the grid and scrolling isn't all too great neither even with shrinkWrap: true and physics: NeverScrollableScrollPhysics().
However when I use a SliverGrid(...), there is this error
RenderObjects expect specific types of children because they coordinate with their children during layout and paint. For example, a RenderSliver cannot be the child of a RenderBox because a RenderSliver does not understand the RenderBox layout protocol.
This obviously makes sense because TabBarView isn't a sliver widget. I have already taken a look at this post but it wasn't really of any help.
How could I implement this? Is there perhaps a way I could create my own widget builder that builds a custom layout?
Thank You!
You need to use SliverOverlapAbsorber/SliverOverlapInjector, the following code works for me (working full code on dart pad):
Here i used SliverFixedExtentList but you can it replace with SliverGrid.
#override
State<StatefulWidget> createState() => _NewsScreenState();
}
class _NewsScreenState extends State<NewsScreen> {
final List<String> listItems = [];
final List<String> _tabs = <String>[
"Featured",
"Popular",
"Latest",
];
#override
Widget build(BuildContext context) {
return Material(
child: Scaffold(
body: DefaultTabController(
length: _tabs.length, // This is the number of tabs.
child: NestedScrollView(
headerSliverBuilder:
(BuildContext context, bool innerBoxIsScrolled) {
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverSafeArea(
top: false,
sliver: SliverAppBar(
title: const Text('Books'),
floating: true,
pinned: true,
snap: false,
primary: true,
forceElevated: innerBoxIsScrolled,
bottom: TabBar(
// These are the widgets to put in each tab in the tab bar.
tabs: _tabs
.map((String name) => Tab(text: name))
.toList(),
),
),
),
),
];
},
body: TabBarView(
// These are the contents of the tab views, below the tabs.
children: _tabs.map((String name) {
return SafeArea(
top: false,
bottom: false,
child: Builder(
// This Builder is needed to provide a BuildContext that is "inside"
// the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can
// find the NestedScrollView.
builder: (BuildContext context) {
return CustomScrollView(
// The "controller" and "primary" members should be left
// unset, so that the NestedScrollView can control this
// inner scroll view.
// If the "controller" property is set, then this scroll
// view will not be associated with the NestedScrollView.
// The PageStorageKey should be unique to this ScrollView;
// it allows the list to remember its scroll position when
// the tab view is not on the screen.
key: PageStorageKey<String>(name),
slivers: <Widget>[
SliverOverlapInjector(
// This is the flip side of the SliverOverlapAbsorber above.
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
),
SliverPadding(
padding: const EdgeInsets.all(8.0),
// In this example, the inner scroll view has
// fixed-height list items, hence the use of
// SliverFixedExtentList. However, one could use any
// sliver widget here, e.g. SliverList or SliverGrid.
sliver: SliverFixedExtentList(
// The items in this example are fixed to 48 pixels
// high. This matches the Material Design spec for
// ListTile widgets.
itemExtent: 60.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// This builder is called for each child.
// In this example, we just number each list item.
return Container(
color: Color((math.Random().nextDouble() *
0xFFFFFF)
.toInt() <<
0)
.withOpacity(1.0));
},
// The childCount of the SliverChildBuilderDelegate
// specifies how many children this inner list
// has. In this example, each tab has a list of
// exactly 30 items, but this is arbitrary.
childCount: 30,
),
),
),
],
);
},
),
);
}).toList(),
),
),
),
),
);
}
}

PageView inside SliverChildBuilderDelegate starting at last page when scrolling fast

I needed a floating SliverAppBar with TabBarView. Each tab has a CustomScrollView to scroll it's Childs. If i put PageView as a child and i scroll fast then the PageView starts with last page instead of first page. I found a sample code here in flutter docs:
https://api.flutter.dev/flutter/widgets/NestedScrollView-class.html
And i just changed the ListTile inside the SliverChildBuilderDelegate with a PageView. But when i test this code and scroll fast the pageView starts with last page.
Here's a demo of that
https://gyazo.com/79709286236fa3dbf1f88b1e92f5cee3
And here's my code:
// This example shows a [NestedScrollView] whose header is the combination of a
// [TabBar] in a [SliverAppBar] and whose body is a [TabBarView]. It uses a
// [SliverOverlapAbsorber]/[SliverOverlapInjector] pair to make the inner lists
// align correctly, and it uses [SafeArea] to avoid any horizontal disturbances
// (e.g. the "notch" on iOS when the phone is horizontal). In addition,
// [PageStorageKey]s are used to remember the scroll position of each tab's
// list.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
/// This is the main application widget.
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
static const String _title = 'Flutter Code Sample';
#override
Widget build(BuildContext context) {
return const MaterialApp(
title: _title,
home: MyStatelessWidget(),
);
}
}
/// This is the stateless widget that the main application instantiates.
class MyStatelessWidget extends StatelessWidget {
const MyStatelessWidget({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
final List<String> _tabs = <String>['Tab 1', 'Tab 2'];
return DefaultTabController(
length: _tabs.length, // This is the number of tabs.
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the SliverAppBar,
// and redirects it to the SliverOverlapInjector below. If it is
// missing, then it is possible for the nested "inner" scroll view
// below to end up under the SliverAppBar even when the inner
// scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver.
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title:
const Text('Books'), // This is the title in the app bar.
pinned: true,
floating: true,
expandedHeight: 150.0,
// The "forceElevated" property causes the SliverAppBar to show
// a shadow. The "innerBoxIsScrolled" parameter is true when the
// inner scroll view is scrolled beyond its "zero" point, i.e.
// when it appears to be scrolled below the SliverAppBar.
// Without this, there are cases where the shadow would appear
// or not appear inappropriately, because the SliverAppBar is
// not actually aware of the precise position of the inner
// scroll views.
forceElevated: innerBoxIsScrolled,
bottom: TabBar(
// These are the widgets to put in each tab in the tab bar.
tabs: _tabs.map((String name) => Tab(text: name)).toList(),
),
),
),
];
},
body: TabBarView(
// These are the contents of the tab views, below the tabs.
children: _tabs.map((String name) {
return SafeArea(
top: false,
bottom: false,
child: Builder(
// This Builder is needed to provide a BuildContext that is
// "inside" the NestedScrollView, so that
// sliverOverlapAbsorberHandleFor() can find the
// NestedScrollView.
builder: (BuildContext context) {
return CustomScrollView(
// The "controller" and "primary" members should be left
// unset, so that the NestedScrollView can control this
// inner scroll view.
// If the "controller" property is set, then this scroll
// view will not be associated with the NestedScrollView.
// The PageStorageKey should be unique to this ScrollView;
// it allows the list to remember its scroll position when
// the tab view is not on the screen.
key: PageStorageKey<String>(name),
slivers: <Widget>[
SliverOverlapInjector(
// This is the flip side of the SliverOverlapAbsorber
// above.
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
),
SliverPadding(
padding: const EdgeInsets.all(8.0),
// In this example, the inner scroll view has
// fixed-height list items, hence the use of
// SliverFixedExtentList. However, one could use any
// sliver widget here, e.g. SliverList or SliverGrid.
sliver: SliverFixedExtentList(
// The items in this example are fixed to 48 pixels
// high. This matches the Material Design spec for
// ListTile widgets.
itemExtent: 48.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// This builder is called for each child.
// In this example, we just number each list item.
return PageView(
/// [PageView.scrollDirection] defaults to [Axis.horizontal].
/// Use [Axis.vertical] to scroll vertically.
scrollDirection: Axis.horizontal,
children: const <Widget>[
Center(
child: Text('First Page'),
),
Center(
child: Text('Second Page'),
),
Center(
child: Text('Third Page'),
)
],
);
},
// The childCount of the SliverChildBuilderDelegate
// specifies how many children this inner list
// has. In this example, each tab has a list of
// exactly 30 items, but this is arbitrary.
childCount: 100,
),
),
),
],
);
},
),
);
}).toList(),
),
),
),
);
}
}
How can i make the pageView to starts with first page? I tried setting this to pageView controller: PageController(initialPage: 0), But it didn't work.

Refresh Indicator not working with NestedScrollView

#override
Widget build(BuildContext context) {
super.build(context);
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
),
child: Scaffold(
key: _scaffoldKeyProfilePage,
body: DefaultTabController(
length: 2,
child:RefreshIndicator(
onRefresh: _onRefresh,
child: NestedScrollView(
headerSliverBuilder: (context, _) {
return [
SliverList(
delegate: SliverChildListDelegate(
[ BuildMainProfile(
....//
),
Padding(
...//another design
),
];
},
// You tab view goes here
body: Column(
children: <Widget>[
TabBar(
tabs: [
Tab(text: 'A'),
Tab(text: 'B'),
],
),
Expanded(
child: TabBarView(
children: [
BuildPost(,
),
BuildWings()
],
),
),
],
),
),),
),
}
Refresh Indicator not working with NestedScrollView, is there any way to implement RefreshIndiactor?
I tried adding an empty ListView in Stack, Refresh indicator start showing but because of that my NestedScrollView doesnt scroll.
Did you try setting NestedScrollView widgets physics to AlwaysScrollableScrollPhysics()?
Please see the documentation for RefreshIndicator.
Refresh indicator does not show up
The RefreshIndicator will appear if its scrollable descendant can be
overscrolled, i.e. if the scrollable's content is bigger than its
viewport. To ensure that the RefreshIndicator will always appear, even
if the scrollable's content fits within its viewport, set the
scrollable's Scrollable.physics property to
AlwaysScrollableScrollPhysics:
ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: ... )
A RefreshIndicator can only be used with a vertical scroll view.
Please try to wrap 'body' of the NestedScrollView with RefreshIndicator widget if the headers won't change when refreshed but only the content change happens for 'body'.

Auto scroll to one of SliverList items when a Tab is pressed

I have a stateful widget with tabs at the top and has a list below it. The list is subdivided into different categories. Each category is listed into each tab. What I want to do is when an item is pressed in the tab, I want its corresponding category in the list to scroll to view.
Here is the relevant code for reference:
SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarTabDelegate(
child: Container(
child: TabBar(
isScrollable: true,
labelColor: Colors.black,
tabs: createTabList(),
controller: tabBarController,
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Padding(
padding: const EdgeInsets.only(top: kSpacerRegular),
child: RestaurantMenuCard(menuCard: menuCards[index]),
);
},
childCount: menuCards.length,
),
),
I searched all day looking for a solution but could not find one. I also tried this package but does not seem to work in my case.
Here is my 'widget tree' for more context:
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(),
SliverPersistentHeader(),
SliverList(),
],
),
);
}
A screen capture for more context
I solved the issue with the help of this answer. I replaced SliverList with SliverToBoxAdapter. The child is the list of my RestaurantMenuCard in a Column. Thus, the resulting widget tree is:
#override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(),
SliverPersistentHeader(),
SliverToBoxAdapter(
Column(
RestaurantMenuCard(),
RestaurantMenuCard(),
...
),
),
],
),
);
}
The magic happens when I added a global key to each instance of the RestaurantMenuCard as discussed in the linked answer.
Then finally, I trigger the Scrollable.ensureVisible(context) in onTap function of my TabBar.