Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

Auto-scroll to the selected tab

When a horizontal tab is tapped, it should become fully visible. Example from bunpod.app:

When using TabBar from the Material package, this is automatically handled for you.

However, if you have a custom one, use Flutter's built-in Scrollable.ensureVisible:

Give each tab a GlobalKey. When one is selected, pass its context to Scrollable.ensureVisible. The alignment: 0.5 centers it in the viewport:

import 'package:flutter/material.dart';
  
class FilterTabs extends StatefulWidget {
  final List<String> labels;
  final int selected;
  final ValueChanged<int> onSelected;
  
  const FilterTabs({
    super.key,
    required this.labels,
    required this.selected,
    required this.onSelected,
  });
  
  @override
  State<FilterTabs> createState() => _FilterTabsState();
}
  
class _FilterTabsState extends State<FilterTabs> {
  // One key per tab, so we can later find and scroll to any of them.
  late final List<GlobalKey> _keys = List.generate(
    widget.labels.length,
    (_) {
      return GlobalKey();
    },
  );
  
  @override
  void initState() {
    super.initState();
  
    // Tabs aren't laid out yet, so wait one frame, then scroll to the
    // tab that's selected when the strip first appears.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollTo(widget.selected);
    });
  }
  
  void _scrollTo(int index) {
    final BuildContext? context = _keys[index].currentContext;
    if (context == null) return;
  
    // Bring the tapped tab fully into view, centered.
    Scrollable.ensureVisible(
      context,
      alignment: 0.5,
      duration: const Duration(milliseconds: 200),
      curve: Curves.easeInOut,
    );
  }
  
  @override
  void didUpdateWidget(FilterTabs oldWidget) {
    super.didUpdateWidget(oldWidget);
  
    // If the selection changed (from a tap or from the parent), scroll to it.
    if (oldWidget.selected != widget.selected) {
      _scrollTo(widget.selected);
    }
  }
  
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 44,
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16),
        itemCount: widget.labels.length,
        itemBuilder: (context, i) {
          final String label = widget.labels[i];
          final bool selected = i == widget.selected;
  
          return _Tab(
            // Attach this tab's key so we can scroll to it.
            key: _keys[i],
            label: label,
            selected: selected,
            onTap: () {
              widget.onSelected(i);
            },
          );
        },
      ),
    );
  }
} 
  
class _Tab extends StatelessWidget {
  final String label;
  final bool selected;
  final VoidCallback onTap;
  
  const _Tab({
    super.key,
    required this.label,
    required this.selected,
    required this.onTap,
  });
  
  @override
  Widget build(BuildContext context) {
    final Color background = selected ? Colors.black : Colors.black12;
    final Color foreground = selected ? Colors.white : Colors.black;
  
    return Padding(
      padding: const EdgeInsets.only(right: 8),
      child: GestureDetector(
        onTap: onTap,
        behavior: HitTestBehavior.translucent,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: background,
            borderRadius: BorderRadius.circular(22),
          ),
          child: Text(
            label,
            style: TextStyle(color: foreground),
          ),
        ),
      ),
    );
  }
}
Kamran Bekirov
Kamran Bekirov

Want this level of care in your Flutter apps?

Work With Me