Keep a TabBar and a scrollable list of sections in sync both ways: scrolling
selects the tab, and tapping a tab scrolls to the section.
dependencies:
scroll_sync_tabs: ^0.1.0class MenuPage extends StatefulWidget {
const MenuPage({super.key});
@override
State<MenuPage> createState() => _MenuPageState();
}
class _MenuPageState extends State<MenuPage> with TickerProviderStateMixin {
late final ScrollTabController<String> _controller;
@override
void initState() {
super.initState();
_controller = ScrollTabController<String>(
items: const ['Popular', 'Desserts', 'Drinks'],
vsync: this,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ScrollTabBar<String>(
controller: _controller,
labelBuilder: (context, item, index) => Text(item),
),
Expanded(
child: ListView.builder(
controller: _controller.scrollController,
itemCount: _controller.items.length,
itemBuilder: (context, i) => ScrollTabAnchor(
controller: _controller,
index: i,
child: SizedBox(height: 300, child: Center(child: Text(_controller.items[i]))),
),
),
),
],
);
}
}This is the package's motivating use case: a pinned app bar, a pinned tab
strip directly below it, and scrollable sections that stay in sync with the
tab strip in both directions. See example/lib/main.dart for the runnable
version this snippet is drawn from.
class _MenuPageState extends State<MenuPage> with TickerProviderStateMixin {
static const double _tabStripHeight = 48;
static const double _pinnedHeaderExtent = kToolbarHeight + _tabStripHeight;
late final ScrollTabController<MenuSection> _controller;
@override
void initState() {
super.initState();
_controller = ScrollTabController<MenuSection>(
items: const [],
vsync: this,
pinnedHeaderExtent: _pinnedHeaderExtent,
);
_load();
}
Future<void> _load() async {
final sections = await fetchMenuSections();
if (!mounted) return;
_controller.updateItems(sections);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
if (_controller.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return CustomScrollView(
controller: _controller.scrollController,
slivers: [
const SliverAppBar(
expandedHeight: 180,
pinned: true,
flexibleSpace: FlexibleSpaceBar(title: Text("Chè Chang's")),
),
ScrollTabBar.sliver<MenuSection>(
height: _tabStripHeight,
controller: _controller,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelBuilder: (context, item, index) => Text(item.title),
),
SliverList.builder(
itemCount: _controller.items.length,
itemBuilder: (context, i) => ScrollTabAnchor(
controller: _controller,
index: i,
child: SectionView(_controller.items[i]),
),
),
],
);
},
);
}
}Note that ScrollTabBar.sliver<MenuSection>(...) is a static method
called with the type argument on the method itself, not a named constructor
(ScrollTabBar<MenuSection>.sliver(...) does not compile — Dart does not
allow a named constructor invocation to carry a generic type argument that
way).
| Type | double, default 0 |
| Meaning | The total height, in logical pixels, of pinned chrome covering the top of the viewport once scrolled — i.e. everything that stays on screen above the currently-visible section. |
| Why required | The controller resolves "which section is selected" by checking which section's top has scrolled to or past this threshold, and scrollTo/jumpTo land a section just below it. There's no reliable way to auto-measure this across arbitrary sliver setups, so it's supplied explicitly (see Limitations). A wrong value causes wrong tab selection and sections landing partially hidden behind pinned chrome. |
| How to compute it | Sum of every pinned sliver above (and including) the tab strip's own height. With only a pinned ScrollTabBar.sliver, it's just that strip's height. With a pinned: true SliverAppBar above it, it's appBar.toolbarHeight (or kToolbarHeight for the default) plus the tab strip height — the collapsed app bar keeps occupying space once pinned, not just its expanded state. |
| Mutable | Yes, via the pinnedHeaderExtent setter, e.g. if pinned chrome height changes at runtime. |
// Pinned SliverAppBar (default toolbar height) + a 48px pinned tab strip:
static const double _pinnedHeaderExtent = kToolbarHeight + 48;Getting this wrong was a real bug hit while building this package's own example app: using only the tab strip's height left tapped sections landing partially behind the app bar, because the app bar's collapsed toolbar chrome was left out of the sum.
Construct the controller with an empty list, then call updateItems once
real data arrives — the controller re-keys anchors and clamps the current
selection for you:
_controller = ScrollTabController<MenuSection>(items: const [], vsync: this);
Future<void> _load() async {
final sections = await fetchMenuSections();
if (!mounted) return;
_controller.updateItems(sections);
}Use TickerProviderStateMixin, not SingleTickerProviderStateMixin —
updateItems disposes and recreates the internal TabController, which needs
a second ticker. SingleTickerProviderStateMixin throws once that happens.
- No auto-measured
pinnedHeaderExtent. You must compute and pass it yourself (see above); the package does not inspect your sliver tree. - Vertical scroll axis only. Horizontal scrolling is not supported.
- No
NestedScrollViewsupport. The controller assumes a single scrollable driving both the tab strip and the sections. - Single correction pass for unmounted anchors. When a tapped section's
anchor isn't mounted yet,
scrollTo/jumpToestimate a proportional offset, wait a frame, then correct once to the exact offset. For sections with very uneven heights, one correction pass may not be perfectly exact; a repeat-until-stable correction loop is deferred to a future release.
