indicator_tab_bar 1.1.0
indicator_tab_bar: ^1.1.0 copied to clipboard
A fixed-width tab indicator that underlines the label rather than the tab, and a sliver AnimatedSwitcher for cross-fading the body each tab selects.
indicator_tab_bar #
A tab indicator that draws a short bar of a fixed width under the tab
instead of one stretched across it, and a sliver AnimatedSwitcher for
cross-fading the body each tab selects.
Nothing here imports Material. TabBar.indicator takes a plain Decoration,
so the indicator works the same whether the TabBar comes from
package:flutter/material.dart or from the material_ui fork — and the sliver
switcher is pure flutter/widgets. No assets, no other pub.flutter-io.cn dependency.

Install #
flutter pub add indicator_tab_bar
Requires Flutter 3.44.0 or newer, and so Dart 3.12.0 or newer — that is the Flutter release which ships it.
LineTabIndicator #
TabBar sizes its indicator from TabBarIndicatorSize, which offers a choice
between the width of the whole tab and the width of its label — both of which
move as the labels do. This one is neither: indicatorWidth is the width it
draws, whatever the tab underneath measures, so a row of tabs with labels of
very different lengths still gets underlines of one length.
TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
indicator: LineTabIndicator(
color: theme.colorScheme.primary,
strokeWidth: 3,
indicatorWidth: 20,
radius: 4,
),
tabs: const [
Tab(text: 'Spot'),
Tab(text: 'Futures & derivatives'),
Tab(text: 'Earn'),
],
)
The bar is a rounded rectangle strokeWidth tall and exactly
indicatorWidth wide, placed inside the tab by alignment — bottom centre
unless told otherwise — within the rect insets leaves. A tab narrower than the
bar gets one that overhangs it evenly on both sides.
color |
what the bar is filled with |
gradient |
fills it in place of color — see below — default null |
strokeWidth |
its height — default 3 |
indicatorWidth |
its width, independent of the tab — default 50 |
radius |
its corner radius, clamped to strokeWidth / 2 — default 0, square ends |
alignment |
where it sits in the tab — default Alignment.bottomCenter |
insets |
deflates the rect it is aligned within — default EdgeInsets.zero |
radius is clamped because past half the height there is nothing left to
round: at or above strokeWidth / 2 the ends are already semicircles. So
radius: 4 on a 3-tall bar and radius: 999 on the same bar draw the same
pill, and the second screenshot row above — radius: 0 — is the only shape
that reads differently at that thickness.
Placement #
alignment and insets decide where in the tab the bar lands, and the default
pair — Alignment.bottomCenter with no insets — is the underline sitting flush
on the bottom edge.
LineTabIndicator(
color: theme.colorScheme.primary,
indicatorWidth: 20,
radius: 4,
// Lift it clear of the bottom edge, so the TabBar divider shows beneath.
insets: const EdgeInsets.only(bottom: 6),
// Tuck it under the leading edge of the label instead of centring it;
// the directional form flips with the reading direction.
alignment: AlignmentDirectional.bottomStart,
)
Alignment.topCenter makes it an overline instead, for a TabBar that sits
below what it selects, and Alignment.center strikes through the label.
Gradient #
LineTabIndicator.gradient fills the bar with a Gradient rather than a flat
colour. The gradient is laid out across the bar, not across the tab, so a
LinearGradient runs over exactly indicatorWidth logical pixels no matter how
wide the tab is.
LineTabIndicator.gradient(
gradient: LinearGradient(
colors: [theme.colorScheme.primary, theme.colorScheme.tertiary],
),
indicatorWidth: 40,
strokeWidth: 4,
radius: 2,
)
The shader is rebuilt only when the bar moves, so a gradient indicator sliding between tabs costs one shader per frame of the slide and none while it rests.
Value semantics #
Two indicators with the same fields are ==, so TabBar does not repaint on a
rebuild that changes nothing, and Decoration.lerp interpolates every field —
alignment, insets and gradient included — so it animates across a theme change.
A lerp against null fades the fill towards transparent without moving the bar.
SliverAnimatedSwitcher #
When the tab body is a sliver inside a CustomScrollView — a SliverList
under a SliverAppBar, say — the plain AnimatedSwitcher cannot be used, twice
over: it stacks its children in a Stack, which is a box, and it wraps them in
box transitions. This keeps the same driving animation but lays the incoming
sliver out on its own and fades it with a SliverFadeTransition.
CustomScrollView(
slivers: [
const SliverAppBar(title: Text('History'), pinned: true),
SliverAnimatedSwitcher(
duration: const Duration(milliseconds: 250),
switchInCurve: Curves.easeOut,
child: SliverList.list(
// The key is what marks this as a *different* sliver.
key: ValueKey(tab),
children: rows,
),
),
],
)

As with AnimatedSwitcher, the swap is detected by Widget.canUpdate: two
slivers of the same runtime type with the same key are the same sliver
updated, and nothing animates. Give each branch its own Key when they share a
type, as the SliverList above does.
transitionBuilder replaces the fade with something else — anything that
returns a sliver, so SliverOpacity and friends, not FadeTransition.
Only the incoming sliver animates #
Slivers cannot be overlaid — there is no sliver Stack to hold the old one
over the new — so the outgoing sliver is removed the moment the swap begins,
and what you see is the incoming one fading up in the space it leaves. That is
why there is no switchOutCurve or reverseDuration here: there would be no
outgoing animation for them to shape.
Example #
example/ is a runnable app: a scrollable TabBar with the indicator under
it, live controls for every field — the three metrics, the inset, the placement
and the gradient — and a CustomScrollView whose body sliver is swapped per
tab.
cd example && flutter run
The images above are rendered from the real widgets, so they can be regenerated whenever the package changes:
cd example && flutter test --update-goldens test/screenshots_test.dart
Licence #
MIT — see LICENSE.
