sliding_segmented_control 1.1.0 copy "sliding_segmented_control: ^1.1.0" to clipboard
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.

Changing tabs: the pill slides while the body slides in behind it

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

The control in three styles

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

Content-sized, scrollable and badged segments

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

A SegmentedBody

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.

1
likes
160
points
265
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A themeable segmented control whose selection is marked by a pill sliding between segments, with an optional body that cross-fades underneath it.

Homepage
Repository (GitHub)
View/report issues

Topics

#segmented-control #tabs #toggle #widget

License

MIT (license)

Dependencies

flutter, material_ui

More

Packages that depend on sliding_segmented_control