I’m building a profile screen in Flutter using a NestedScrollView. I already have a SliverOverlapAbsorber for my SliverAppBar, but I need to add another one for my TabBar. Due to dynamic content with varying heights in the middle, I can’t use the SliverOverlapAbsorber as a bottom widget. How can I properly configure my profile widget to handle this situation?
Here is my current setup:
• NestedScrollView with SliverAppBar.
• SliverOverlapAbsorber for the SliverAppBar.
• Dynamic content in the middle section.
• Need to add SliverOverlapAbsorber for TabBar without affecting the existing setup.
Any guidance or examples would be greatly appreciated!
My full code:
class SimpleProfile extends StatefulWidget {
const SimpleProfile({
super.key,
});
@override
_SimpleProfileState createState() => _SimpleProfileState();
}
class _SimpleProfileState extends State<SimpleProfile> with SingleTickerProviderStateMixin {
late ScrollController _scrollController;
late TabController _tabController;
@override
void initState() {
_scrollController = ScrollController();
super.initState();
_tabController = TabController(length: 6, vsync: this);
}
@override
void dispose() {
_scrollController.dispose();
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: _tabController.length,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
_profileAppBar(innerBoxIsScrolled),
_profileMainInfo(),
_tabBarSelector(),
];
},
body: Builder(
builder: (context) => _tabBarView(),
),
),
),
);
}
Widget _profileAppBar(bool innerBoxIsScrolled) {
return SliverAppBar(
floating: false,
pinned: true,
stretch: true,
forceElevated: innerBoxIsScrolled,
surfaceTintColor: Colors.white,
expandedHeight: 300,
backgroundColor: Colors.black45,
title: null,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final bgHeight = constraints.maxHeight * 0.85;
const avatarSize = 104.0;
const borderWidth = 2;
final radius = avatarSize / 2;
final imgSize = (radius - borderWidth) * 2;
return Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: SizedBox(
height: bgHeight,
child: Container(
color: Colors.blue,
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: CircleAvatar(
radius: radius,
backgroundColor: Colors.white,
child: ClipOval(
child: Container(
color: Colors.blueAccent,
width: imgSize,
height: imgSize,
),
),
),
),
),
],
);
},
),
),
SizedBox(
height: 20,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'First and Last Names',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w600),
),
),
],
),
),
);
}
Widget _profileMainInfo() {
return SliverToBoxAdapter(
child: Container(
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//some horizontal list
Container(color: Colors.amber, height: 35),
const SizedBox(
height: 20,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: //some static height Container
Container(color: Colors.green, height: 75),
),
if (true) ...[
const SizedBox(
height: 20,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Some short/medium long text',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 17,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w300,
height: 22 / 17,
letterSpacing: -0.408,
textBaseline: TextBaseline.alphabetic,
),
),
),
],
const SizedBox(
height: 20,
),
],
),
),
);
}
Widget _tabBarSelector() {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
TabBar(
padding: const EdgeInsets.symmetric(horizontal: 12),
controller: _tabController,
tabAlignment: TabAlignment.start,
isScrollable: true,
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
labelColor: Colors.black,
indicatorColor: Colors.black,
unselectedLabelColor: Colors.grey,
tabs: const [
Tab(text: 'tab 1'),
Tab(text: 'tab 2'),
Tab(text: 'tab 3'),
Tab(text: 'tab 4'),
Tab(text: 'tab 5'),
Tab(text: 'tab 6'),
],
),
),
);
}
Widget _tabBarView() {
return TabBarView(
controller: _tabController,
children: List.generate(6, (index) {
return Builder(
builder: (BuildContext context) {
return CustomScrollView(
key: PageStorageKey<String>('SomeWidget$index'),
slivers: <Widget>[
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverFixedExtentList(
itemExtent: 48.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
title: Text('Item $index'),
);
},
childCount: 30,
),
),
),
],
);
},
);
}),
);
}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate(this._tabBar);
final TabBar _tabBar;
@override
double get minExtent => _tabBar.preferredSize.height + 1;
@override
double get maxExtent => _tabBar.preferredSize.height + 1;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Column(
children: [
Container(
color: Colors.white,
child: _tabBar,
),
Divider(
height: 1,
color: Colors.grey.withOpacity(0.2),
thickness: 1,
),
],
);
}
@override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return false;
}
}
Whenever I change tabs, the inner scroll content starts from the top of the screen under the app bar. I need to add an inset to maintain the scroll position.
enter image description here
wrap SliverAppBar
with SliverOverlapAbsorber
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
),
);
and for each tab add SliverOverlapInjector
CustomScrollView(
slivers: [
// add this line
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
SliverPadding(
padding: padding,
sliver: sliver,
),
],
),
4