Flutter Pinned TabBar with triggered scrolling and page anchors — Slivers + Easy Way with PageView

Mahmoud Abdellatief
6 min readApr 30, 2020

--

Today we will build a fancy UI with flutter. Including a disappearing part with scroll, Pinned TabBar & triggered scrolling through TabBar onTap, so that when you click on any of the tabs, it scrolls to its own page section! all with Slivers!

Update : I’ve found an easier way to do this ( vertical scrollable tabBarView ) with PageView.

With PageView disadvantages :

  • every widget will be a full page size.
  • doesn’t feel perfect with scrolling.
  • you will not have a disappearing widget above tabbar like with slivers.

so, let’s start with the easy way first :

add pageController & tabController and initiate them :

PageController _pageController;
TabController _tabController;
@override
void initState() {
// TODO: implement initState
super
.initState();
_pageController = PageController(initialPage: 0,);
_tabController = new TabController(length: 4, vsync: this);
}

create your TabBar & PageView & Wrap them in a column :

Column(
// controller: _scrollController,
children: <Widget>[
TabBar(
onTap: (val) {

},
unselectedLabelColor: Colors.grey.shade700,
indicatorColor:Colors.red,
indicatorWeight: 2.0,
labelColor: Colors.red,
controller: _tabController,
tabs: widget.tabs,
indicatorSize: TabBarIndicatorSize.tab,

),
Expanded(
child: PageView(
scrollDirection: Axis.vertical,
physics: RangeMaintainingScrollPhysics(),
controller: _pageController,
children: [],
),
),
],
);
  • to have real scroll feel, we will add the RangeMaintainingScrollPhysics()

in the TabBar on Tap animate to the page required :

_pageController.animateToPage(val, duration: Duration(milliseconds: 100), curve: Curves.elasticInOut);

then add a listener to the _pageController to animate the tabbar whenever the page is scrolled :

pageController.addListener(() {

_tabController.animateTo(pageController.page.toInt());
});

And that’s it !

now with the Difficult example with slivers !

First, We Will Create our SliverAppBarDelegate Class for the persistent header ( TabBar ).

class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate({
@required this.minHeight,
@required this.maxHeight,
@required this.child,
});
final double minHeight;
final double maxHeight;
final Widget child;
@override
double get minExtent => minHeight;
@override
double get maxExtent => math.max(maxHeight, minHeight);
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return new SizedBox.expand(child: child);
}

@override
bool shouldRebuild(_SliverAppBarDelegate oldDelegate) {
return maxHeight != oldDelegate.maxHeight ||
minHeight != oldDelegate.minHeight ||
child != oldDelegate.child;
}
}

Then We will create our Persistent TabBar Header Widget.

SliverPersistentHeader makeTabBarHeader() {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverAppBarDelegate(
minHeight: 50.0,
maxHeight: 50.0,
child: Container(
color: Colors.white,
child: TabBar(
onTap: (val) {},
unselectedLabelColor: Colors.grey.shade700,
indicatorColor: Colors.red,
indicatorWeight: 2.0,
labelColor: Colors.red,
controller: _tabController,
tabs: <Widget>[
new Tab(
child: Text(
"Green",
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
new Tab(
child: Text(
"Blue",
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
new Tab(
child: Text(
"Orange",
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
new Tab(
child: Text(
"Yellow",
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
],
indicatorSize: TabBarIndicatorSize.tab,
),
),
),
);
}

Then We Create our HomePage StatefulWidget.

we will create a stateful widget with TickerProviderStateMixin for the TabBar, inside the Scaffold Body we will have a CustomScrollView with ScrollController and Slivers, we will put the upper section that we want to disappear first, then our Persistent TabBar, then one sliverList for the rest of our scrollable page sections. and we will give each of them a unique Global key. Also, add a ScrollController and pass it to the CustomScrollView, and the same for the TabBarController, and instantiate them in the initState.

class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with
TickerProviderStateMixin {
//Global Keys for all widgets in our pagefinal greenKey = new GlobalKey();
final blueKey = new GlobalKey();
final orangeKey = new GlobalKey();
final yellowKey = new GlobalKey();
ScrollController scrollController;TabController _tabController;@override
void initState() {
// TODO: implement initState
super
.initState();
scrollController = ScrollController();
_tabController = new TabController(length: 4, vsync: this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(),
body: CustomScrollView(
controller: scrollController,
slivers: <Widget>[
SliverList(
delegate: SliverChildListDelegate(
[
Container(
height: 200,
color: Colors.black,
),
],
),
),
makeTabBarHeader(),
SliverList(
delegate: SliverChildListDelegate(
[
Container(
key: greenKey,
height: 800,
color: Colors.green,
),
Container(
key: blueKey,
height: 800,
color: Colors.blue,
),
Container(
key: orangeKey,
height: 800,
color: Colors.orange,
),
Container(
key: yellowKey,
height: 800,
color: Colors.yellow,
),
],
),
),
],
),
);
}
}

Now we have the UI Ready, let’s get it working.

in the TabBar onTap we will add this simple Switch function to scroll to the page section.

onTap: (val) {
switch (val) {
case 0:
{
scrollController.position.ensureVisible(
greenKey.currentContext.findRenderObject(),
alignment: 0.0,
// How far into view the item should be scrolled (between 0 and 1).
duration: const Duration(milliseconds: 300),
);
}
break;
case 1:
{
scrollController.position.ensureVisible(
blueKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
}
break;
case 2:
{
scrollController.position.ensureVisible(
orangeKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
}
break;
case 3:
{
scrollController.position.ensureVisible(
yellowKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
}
break;
}
},

Depends on how long your widget is, you might get an error like this :

The following NoSuchMethodError was thrown while handling a gesture:
The method ‘findRenderObject’ was called on null.
Receiver: null
Tried calling: findRenderObject()

Don’t worry! Simply, this means that the widget you are trying to jump to is not rendered yet! luckily, we have a work-around for that!

Before firing the .ensureVisible function, we will make sure first that our desired widget’s context exists, and if not, we will make it scroll one widget at a time until it reaches our desired widget!

Here’s a simple example if we press on the orange tab

// check first if orange widget context is not nullif (orangeKey.currentContext != null) {scrollController.position.ensureVisible(
orangeKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
} else {// first ensure that the green widget is visiblescrollController.position.ensureVisible(
greenKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
).then((value){
//then ensure that the yellow widget is visiblescrollController.position.ensureVisible(
yellowKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
).then((value){
//then finally scroll to our desired orange widgetscrollController.position.ensureVisible(
orangeKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
});});}

the previous scenario was if the user is coming from the top ( green widget ) to ( orange widget ), now what if the user is coming from the bottom ( yellow widget ) to orange widget ?.

we would add a simple “ if “ :

if (_tabController.previousIndex == 0) {// do the previous code} else {// do the same code but reversed ;), if (orangeKe.currentContext != null) {
scrollController.position.ensureVisible(
orangeKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
} else {
// first ensure that the top of the yellow widget is visible
// alignment = 0.0
scrollController.position.ensureVisible(
yellowKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
).then((value){
//then finally scroll to our desired orange widgetscrollController.position.ensureVisible(
orangeKey.currentContext.findRenderObject(),
alignment: 0.0,
duration: const Duration(milliseconds: 300),
);
});}
}

Great! now triggered scroll is working! But Still one more thing to go,

when we manually scroll through the page, the TabBar doesn’t get noticed to move to the corresponding tab!

for that we will first need to get the height of each widget from it’s own key, so that we can pass it to the scrollController listener to notify the tabBarController to move to the corresponding tab.

// add these to our widget, we will instantiate them in the next 
// step
double greenHeight;
double blueHeight;
double orangeHeight;
double yellowHeight;

Now we need to instantiate these Heights in the build method not in the initState Method, because not all of our widgets contexts will be ready in the initState but they get ready as we scroll down.

if (greenKey.currentContext != null) {
greenHeight = greenKey.currentContext.size.height;
}
if (blueKey.currentContext != null) {
blueHeight = blueKey.currentContext.size.height;
}
if (orangeKey.currentContext != null) {
orangeHeight = orangeKey.currentContext.size.height;
}
if (yellowKey.currentContext != null) {
yellowHeight = yellowKey.currentContext.size.height;
}

now we got our widget’s heights, we can compare them with the scroll controller offset to notify the tabbar when to move to the corresponding tab!

scrollController.addListener(() {// we will need to check if user scrolling up or down  if (scrollController.position.userScrollDirection ==
ScrollDirection.reverse) {
if (scrollController.offset > 0 &&
scrollController.offset < greenHeight) {
_tabController.animateTo(0); } else if (scrollController.offset > greenHeight &&
scrollController.offset < blueHeight + greenHeight) {
_tabController.animateTo(1);
} else if (scrollController.offset > blueHeight + greenHeight &&
scrollController.offset <
blueHeight + greenHeight + orangeHeight) {
_tabController.animateTo(2);
} else if (scrollController.offset >
blueHeight + greenHeight + orangeHeight &&
scrollController.offset <=
blueHeight + greenHeight + orangeHeight + yellowHeight) {
_tabController.animateTo(3); } else {} } else if (scrollController.position.userScrollDirection ==
ScrollDirection.forward) {
if (scrollController.offset < greenHeight) { _tabController.animateTo(0); } else if (scrollController.offset > greenHeight &&
scrollController.offset < blueHeight + greenHeight) {
_tabController.animateTo(1); } else if (scrollController.offset > blueHeight + greenHeight &&
scrollController.offset < blueHeight + greenHeight + orangeHeight) {
_tabController.animateTo(2); } else if (scrollController.offset >
blueHeight + greenHeight + orangeHeight &&
scrollController.offset <
blueHeight + greenHeight + orangeHeight + yellowHeight) {
_tabController.animateTo(3); } else {}
}
});

and that’s it! we successfully created a complex UI with Flutter!

Update: i added full source code for this example on GitHub HERE.

--

--