Skip to content

Repository files navigation

scroll_sync_tabs

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.

The example app running in iOS

Install

dependencies:
  scroll_sync_tabs: ^0.1.0

Minimal usage (ListView)

class 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]))),
            ),
          ),
        ),
      ],
    );
  }
}

CustomScrollView + pinned SliverAppBar recipe

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).

pinnedHeaderExtent

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.

Async item loading (updateItems)

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 SingleTickerProviderStateMixinupdateItems disposes and recreates the internal TabController, which needs a second ticker. SingleTickerProviderStateMixin throws once that happens.

Limitations

  • 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 NestedScrollView support. 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/jumpTo estimate 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.

About

Keep a TabBar and a scrollable list of sections in sync both ways - scrolling selects the tab, tapping a tab scrolls to the section.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages