sliding_segmented_control 1.1.0
sliding_segmented_control: ^1.1.0 copied to clipboard
A themeable segmented control whose selection is marked by a pill sliding between segments, with an optional body that cross-fades underneath it.
sliding_segmented_control #
A segmented control whose selection is marked by a pill that slides between
segments, rather than by a highlighted button — plus SegmentedBody, which
pairs the control with a body that cross-fades as the pill moves.
The selection can be tapped, dragged — press the pill and slide it, as on iOS — or moved with the arrow keys. Segments share the width equally, take their own width, or scroll.
Material comes from the material_ui package rather than from
package:flutter/material.dart, so your app has to be on material_ui too —
its ThemeData and ColorScheme are not the framework's. Beyond that the
control asks nothing of you: no assets, no localisations, no other pub.flutter-io.cn
dependency. Colours, shapes and metrics come from a ThemeExtension you
register, and with none registered it derives a palette from the ambient
ColorScheme.
Still on framework Material? Stay on 0.1.1.

The control in three styles — default, with icons, and with a solid indicator:

Segments sized to their content, a scrollable track, and a badge:

And a SegmentedBody, the control above the body of the selected segment:

Install #
flutter pub add sliding_segmented_control
Or add it to pubspec.yaml yourself — it is a runtime dependency:
dependencies:
material_ui: ">=1.0.0 <3.0.0"
sliding_segmented_control: ^1.1.0
then:
flutter pub get
Use #
The control on its own, driven by you:
SlidingSegmentedControl(
segments: const [
Segment(label: 'Buy'),
Segment(label: 'Sell'),
Segment(label: 'History'),
],
selectedIndex: _index,
onSegmentChanged: (i) => setState(() => _index = i),
)
Or the control with its body, keeping the selection for you:
SegmentedBody(
pages: [
SegmentPage.of(label: 'Open', child: const OpenPositions()),
SegmentPage.of(label: 'Closed', child: const ClosedPositions()),
],
)
Only the selected page is built, so the others cost nothing until they are
picked. Pass selectedIndex and onSegmentChanged to drive it yourself
instead.
SlidingSegmentedControl #
| Parameter | Default | Meaning |
|---|---|---|
segments |
— | The segments, sharing the width equally. Must not be empty |
selectedIndex |
— | Index of the selected segment |
onSegmentChanged |
— | Called with the newly selected index, however it was picked |
enabled |
true |
Whether the control accepts input at all |
sizing |
equal |
equal, intrinsic or scrollable — see Sizing |
enableDrag |
true |
Whether the pill can be dragged between segments |
enableFeedback |
true |
Whether a selection change fires haptic feedback |
autofocus |
false |
Whether the selected segment takes focus on first build |
scrollController |
null | Controller for a scrollable control's scroll view |
duration |
250 ms | How long the indicator takes to slide |
curve |
easeInOut |
The curve it slides on |
height |
theme | Overrides the theme's height |
padding |
theme | Inset between the track edge and the segments |
segmentPadding |
theme | Inset between a segment's edge and its content |
trackRadius / indicatorRadius |
theme | Per-instance corner radii |
trackShape / indicatorShape |
theme | Per-instance ShapeBorders, overriding the radii |
labelStyle / selectedLabelStyle |
theme | Per-instance text styles |
trackColor, indicatorColor, selectedLabelColor, unselectedLabelColor |
theme | Per-instance colours |
semanticLabel |
null | Screen-reader label for the control as a whole |
A Segment is a label, an optional icon, and enabled — a disabled
segment is dimmed and ignores input. It also takes a semanticLabel and a
tooltip, and three ways to draw something other than plain text:
| Field | What it does |
|---|---|
iconWidget |
A widget in place of icon — an avatar, an SVG, a flag. Takes the segment's colour through IconTheme |
badge |
A widget after the label — a count, a dot, a status chip |
child |
Replaces the icon and label outright. label stays on as the screen-reader text |
Segment(label: 'Inbox', icon: Icons.inbox_outlined, badge: const Text('12'))
Segment and SegmentPage compare by value and have a copyWith.
Sizing #
sizing decides how the segments share the control's width.
SegmentSizing |
What it does |
|---|---|
equal |
Every segment is the same width and the control fills its parent. The default |
intrinsic |
Each segment is as wide as its content and the control shrink-wraps them. Content that does not fit is squeezed proportionally rather than overflowing |
scrollable |
Content-sized segments in a horizontal scroll view, which keeps the selected segment in view |
The pill takes the width of whichever segment it is under, so it grows and shrinks as it slides between segments of different widths.
Dragging #
Press the pill and slide it: the pill follows the pointer, ticks out haptic
feedback as it crosses into each segment, and commits to the segment nearest
where it is let go. Set enableDrag: false to leave dragging alone, or
enableFeedback: false for the haptics only. A press that starts anywhere but
on the pill is an ordinary tap.
SegmentedBody #
| Parameter | Default | Meaning |
|---|---|---|
pages |
— | SegmentPages: a segment and the body it shows |
selectedIndex |
null | Pass it to control the selection; leave null to let the widget keep it |
initialIndex |
0 |
The index selected first, when uncontrolled |
onSegmentChanged |
null | Called with the new index, in both modes |
sizing |
equal |
How the control's segments share its width |
enableDrag / enableFeedback / autofocus |
true / true / false |
Forwarded to the control |
spacing |
14 |
Gap between the control and the body |
controlMargin |
zero |
Padding around the control, outside its track |
bodyExpanded |
false |
Whether the body fills the remaining height |
bodyDuration |
200 ms | How long the body takes to change |
bodyTransition |
fade |
One of the ready-made transitions below |
transitionBuilder |
null | A transition of your own, overriding bodyTransition |
Leave bodyExpanded false inside a scroll view, where the height is unbounded.
Body transitions #
SegmentedBody(
bodyTransition: SegmentedBodyTransition.slide,
pages: [...],
)
SegmentedBodyTransition |
What it does |
|---|---|
fade |
The bodies cross-fade in place. The default, and the cheapest |
slide |
The incoming body slides in from the side the selection moved towards while the outgoing one leaves the other way, fading across |
scale |
The incoming body grows into place as the outgoing one shrinks away |
slide is direction-aware: picking a later segment brings the body in from the
end side, picking an earlier one brings it from the start side, and both are
mirrored under TextDirection.rtl. It reads the direction from the selection
itself, so it works the same whether the widget keeps the selection or you do.
For anything else, pass a transitionBuilder — it takes precedence over
bodyTransition:
SegmentedBody(
transitionBuilder: (child, animation) =>
RotationTransition(turns: animation, child: child),
pages: [...],
)
Theming #
Register a SegmentedControlTheme so every control in the app is styled in one
place, and follows your light and dark themes:
MaterialApp(
theme: ThemeData(
extensions: [
SegmentedControlTheme(
trackColor: scheme.surfaceContainerLow,
borderColor: scheme.outlineVariant,
indicatorColor: scheme.primary.withValues(alpha: 0.14),
selectedLabelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
),
],
),
);
Every field is optional beyond those five, and the widget's own parameters win over whatever the extension says.
| Field | Default | Meaning |
|---|---|---|
trackColor |
— | Background behind the segments |
borderColor / borderWidth |
— / 1 |
The track outline. Width 0 drops it |
indicatorColor |
— | Fill of the sliding pill |
selectedLabelColor |
— | Label and icon of the selected segment |
unselectedLabelColor |
— | Label and icon of every other segment |
disabledLabelColor |
38% of unselected | Label of disabled segments |
labelStyle / selectedLabelStyle |
ambient bodyMedium |
Text styles. What they set wins |
fontFamily |
null | Swaps the typeface without touching the styles |
trackRadius / indicatorRadius |
8 / 4 | Corner radii. Any BorderRadiusGeometry |
trackShape / indicatorShape |
rounded | Full ShapeBorders, overriding the radii |
smoothCorners |
true |
Squircle corners, or plain circular ones |
trackPadding |
4 |
Inset between the track edge and the segments |
segmentPadding |
12 horizontal |
Inset between a segment's edge and its content, which is what gives a content-sized segment its width |
hoverColor |
6% of unselected | Overlay on the segment under the pointer |
focusColor |
10% of unselected | Overlay on the segment holding keyboard focus |
focusOutlineColor / focusOutlineWidth |
scheme primary / 2 |
The focus ring. Width 0 drops it |
height |
44 |
Height of the whole control |
iconSize / iconLabelSpacing |
14 / 4 |
Segment icon metrics |
indicatorShadows |
none | Shadows cast by the pill |
With no extension registered, SegmentedControlTheme.of falls back to
SegmentedControlTheme.fromScheme(Theme.of(context).colorScheme) — a tinted
indicator on a surface track — so the control is usable with no setup.
copyWith and lerp are implemented, so the control animates across a theme
change like any other ThemeExtension.
Corners #
trackRadius and indicatorRadius take any BorderRadiusGeometry — one
radius for all four corners, a different radius per corner, elliptical corners,
or BorderRadiusDirectional, which the shape resolves against the ambient text
direction:
SlidingSegmentedControl(
trackRadius: const BorderRadius.only(
topLeft: Radius.circular(22),
bottomRight: Radius.elliptical(12, 6),
),
indicatorRadius: BorderRadius.circular(18),
// …
)
By default the corners are drawn as a superellipse — the smoothed, iOS-style
squircle — through Flutter's own RoundedSuperellipseBorder, which the engine
rasterises directly. Set smoothCorners: false on the theme for plain circular
corners.
For corner geometry this package does not ship, pass a whole ShapeBorder as
trackShape / indicatorShape, on the theme or per instance. That is the seam
for a StadiumBorder, your own ShapeBorder, or a squircle from a package
such as figma_squircle — the shape comes from you, so the dependency stays in
your app rather than in this one:
SlidingSegmentedControl(
trackShape: SmoothRectangleBorder(
borderRadius: SmoothBorderRadius(cornerRadius: 8, cornerSmoothing: 1),
),
// …
)
Right-to-left #
Segments are laid out along the text direction, so under TextDirection.rtl
the first segment is on the right and the pill slides leftwards. Drags and the
arrow keys mirror with it. Nothing to configure.
Keyboard and accessibility #
The control is a single tab stop, like a radio group: Tab reaches the selected segment and the arrow keys move from there.
| Key | What it does |
|---|---|
| ← / → | Moves the selection one segment, stepping over disabled ones and stopping at the ends. Mirrored under RTL |
| Home / End | Moves to the first or last enabled segment |
| Space / Enter | Selects the focused segment |
The focused segment is drawn with focusColor behind it and a ring in
focusOutlineColor, and the segment under the pointer takes hoverColor.
Each segment is exposed as a button carrying its selected and enabled state,
with a tap action, labelled by Segment.semanticLabel or its label. Note the
default height of 44 is under the 48 dp minimum tap target — raise it on the
theme where that matters.
Testing against it #
The sliding indicator carries SlidingSegmentedControl.indicatorKey and each
segment carries SlidingSegmentedControl.segmentKey(index), so host tests can
find and measure them:
final pill = tester.getRect(
find.byKey(SlidingSegmentedControl.indicatorKey),
);
final second = tester.getRect(
find.byKey(SlidingSegmentedControl.segmentKey(1)),
);
// Drag the pill to the last segment.
await tester.drag(
find.byKey(SlidingSegmentedControl.segmentKey(0)),
const Offset(200, 0),
);
Example #
A runnable demo of every style, including the RTL toggle, is in
example/:
cd example && flutter run
The images in this README are rendered from the real widgets, so they can be regenerated whenever the control changes:
cd example && flutter test --update-goldens test/screenshots_test.dart && flutter test tool/record_tab_gif.dart
Licence #
MIT — see LICENSE.
