Skip to main content

Module button_group

Module button_group 

Source
Expand description

The Material 3 Expressive button group: a row (or column) of crate::toggle_button members, standard (spaced) or connected (shared-edge), with the reference’s neighbor-squish press animation.

Every member is a real crate::ToggleButtonView — the group hands it ToggleButtonView::group_connected/ first_in_group/ last_in_group and that widget owns its own corner asymmetry, state layer, shape morph and haptics. This module owns only what a group adds: the axis layout, the resolved gap, the squish, the selection fan-out, and the overflow split.

§Types × densities × direction

AxisValuesReference
ButtonGroupTypeStandard (gaps) / Connected (shared edges)M3EButtonGroupType, m3e_toggle_button_group_enums.dart:9
ButtonGroupDensityRegular / Compact (75% of the token gap, floored to whole dp)M3EButtonGroupDensity, :21
ButtonGroupDirectionHorizontal / VerticalM3EButtonGroup.direction (Axis)

The resolved member gap (resolve_spacing) is:

TypeRegularCompact
Standard8dp (standardSpacing)6dp (floor(8 × 0.75))
Connected2dp (connectedGap)2dp

The connected row is density-independent on purpose, transcribed not “fixed”. M3EToggleButtonGroupTheme.metricsFor does compute a density-scaled connected gap (m3e_toggle_button_group_theme.dart:86), but _buildGroup then discards it — spacing = widget.spacing ?? (connected ? 0.0 : metrics.spacing) (..._build.dart:13) — and the separator each connected member actually gets is re-resolved from the raw connectedGap by _buildGap/_separatorMainExtent (..._layout.dart:518/:542). An explicit ButtonGroupView::spacing always wins over both.

§Selection: single, multi, or uncontrolled

A controlled component in all three modes (see docs/CODE_STANDARDS.md’s Interaction Semantics): a tap reports the requested selection and leaves this view’s own props untouched until the app feeds them back. _resolveToggleActionSelected (..._build.dart:96) resolves which member reads checked, in this precedence:

  1. ButtonGroupView::selected_indices — multi-select; membership in the set.
  2. ButtonGroupView::on_selected_index_changed or ButtonGroupView::selected_index — single-select; selected == Some(i).
  3. otherwise the per-action ButtonGroupAction::checked flag (uncontrolled).

and _onToggleCheckedChange (:184) fans a tap out in the same order: the indices callback wins, then the index callback (which reports None when the tap deselects the current member), then — this port’s own third arm — the legacy button_group on_select, which always reports the tapped index (its pre-rework contract, unchanged).

§Neighbor squish (ButtonGroupView::neighbor_squish, default on)

Pressing a member grows it by natural × expanded_ratio × anim and its neighbours absorb exactly that growth, so the row’s total main extent is unchanged (M3ERenderButtonGroup._applySquishSizes/ _shrinkNeighborsForSquish, ..._render.dart:245/:273):

  • a middle member: each neighbour gives up growth / 2;
  • the first member: the single right neighbour gives up all of it;
  • the last member: the single left neighbour gives up all of it;
  • a one-member group grows with nothing to absorb it.

Every shrink is floored at 0. squish_sizes is the transcription, unit tested directly. The reference gates the whole animation on _supportsAnimatedSquish (m3e_toggle_button_group.dart:217) — horizontal and not connected and neighborSquish — and so does ButtonGroupWidget::supports_squish. It is a layout animation, so it drives PaintCtx::request_layout, never a bare request_frame, and it is disabled outright under Theme::motion.reduce_motion (the reference has no reduced-motion concept; freezing the squish is this port’s own substitution, per docs/WIDGETS_CODE_STANDARDS.md).

Including the frame the spring lands on, which asks for nothing of its own: AnimationController::advance reports false on the very pass that snaps the value onto its target, and the two state moves that end a press (releasing the pressed member, the reduced-motion freeze) animate nothing either. So paint compares the live squish state against the one layout actually ran with and asks for the missing pass itself — otherwise the row keeps the geometry of the last in-flight frame, a residue far too small to read as a box edge (~1e-4 px) yet perfectly able to leave a member a hair under its natural width for good. What a member does with a width a hair under natural is crate::toggle_button’s own clipping contract; the two together are why a settled group looks exactly like one that was never pressed.

[SQUISH_SPRING] is M3EButtonMotion.standard (stiffness 1200, ζ 0.8, m3e_button_motion.dart:23) — a named constant rather than a crate::MaterialSpring token because no preset carries that pair, and rather than a paint-time theme read because a press starts in the event pass, which threads no theme.

§Overflow

ButtonGroupOverflowBehaviour
NoneLay every member out; content wider than the box simply overflows it (_buildAnimatedLinearLayout under an unclipped LayoutBuilder).
Scroll (default)Lay every member out, clip to the box, and pan along the main axis once the content does not fit (_linearScrollable).
BottomSheetShow as many members as fit plus a trailing overflow trigger, and report the first hidden index through ButtonGroupView::on_overflow when the trigger is tapped — M3EButtonGroupOverflow.menu with M3EButtonGroupOverflowMenuStyle.bottomSheet.
PopupThe same split and trigger as BottomSheet, but the hidden tail presents as an anchored menu via ButtonGroupView::overflow_menu — M3EButtonGroupOverflow.menu with M3EButtonGroupOverflowMenuStyle.popup.

BottomSheet and Popup share one split, visible_count_for_overflow, the exact transcription of M3EButtonGroupOverflowController.computeVisibleCountForMenu (m3e_button_group_overflow_controller.dart:61), including its roundConsumed/roundAvailable (ceil/floor) rounding and its rule that room for the trigger and its separator is reserved as soon as anything would remain hidden.

The sheet itself is the app’s to present; the popup is this module’s. crate::show_bottom_sheet needs a NavigatorController a leaf widget cannot reach, and its build closure is re-invoked on every navigator rebuild — so BottomSheet reports the split and leaves presentation to on_overflow, exactly the seam crate::split_button uses for its own menu. Popup needs no navigator at all: crate::overlay::anchored is a plain widget, so ButtonGroupView::overflow_menu composes one directly from the same action list on_overflow already reports the split over — the two-piece field/panel shape crate::dropdown takes, since the trigger paints inside the group’s own box and there is no portal to reach out through: the app mounts ButtonGroupView::overflow_menu separately (typically the top of its own frust::Stack), anchored to the OverlayAnchor handed to ButtonGroupView::overflow_anchor. This mirrors upstream’s own _onCustomOverflowPressed → showOverflowMenu → onItemSelected flow (m3e_toggle_button_group_layout.dart:181-:203): a trigger tap never fires a hidden action directly, it opens the picker surface first, and only a row activation inside it reports a real selection. Two consequences of the split being a layout-time result the view cannot see: the trigger never reads checked (the reference marks it checked while the hidden range holds the selection, _buildOverflowIndicatorButton), and a group too narrow for even one member leaves the trigger’s leading corner inner rather than outer (isFirst: visibleCount == 0).

experimentalPaging (and with it M3EOverflowStrategy, the custom strategy seam) is deferred by approved decision.

§Migrated off the legacy radial shape model

The pre-rework module painted its own member backgrounds and a pressed “shape emphasis” overlay through the crate’s legacy radial-model polygon (the crate docs name the module; this one no longer references it, and this_module_no_longer_uses_the_legacy_radial_shape_model is the tripwire). Both are gone: a member is a real toggle button, which paints its own container, state layer and per-corner shape morph. member_radii is kept as the group’s documented outer/inner corner split (M3EToggleButtonGroupTheme.connectedRadiusFor); the shipped members resolve their own corners through toggle_button’s equivalent.

§Semantics

One Role::Group container (the reference’s Semantics(container: true, label: semanticLabel)) forwarding every visible member’s own node — a toggle button reports Role::Button + Toggled, so the group contributes no role of its own per member. Members hidden behind the overflow trigger are omitted rather than flagged hidden, under the input-parity carve-out in docs/CODE_STANDARDS.md’s Semantics Conventions: input cannot reach them.

§Attribution

See plugins/material/NOTICE’s “MIT License — Additional Copyright Holders (Vendored Components)” section (Mudit Purohit / m3e_buttons) and its Module Attribution Header Convention.

Structs§

ButtonGroupAction
A declarative description of one member — M3EButtonGroupAction (models/m3e_button_group_action.dart:12).
ButtonGroupView
A declarative M3 Expressive button group. See the module docs.
ButtonGroupWidget
The retained widget for a ButtonGroupView. See the module docs.

Enums§

ButtonGroupDensity
Spacing compactness between adjacent members — M3EButtonGroupDensity (m3e_toggle_button_group_enums.dart:21).
ButtonGroupDirection
The group’s primary layout axis — M3EButtonGroup.direction (Flutter’s Axis).
ButtonGroupOverflow
How the group handles members that do not fit — the ported subset of M3EButtonGroupOverflow (m3e_toggle_button_group_enums.dart:30). See the module docs’ Overflow section for the one unported mode (experimentalPaging).
ButtonGroupType
How members are visually connected — M3EButtonGroupType (m3e_toggle_button_group_enums.dart:9).

Constants§

INNER_RADIUS
Unthemed-fallback inner (adjacent) corner radius (a theme resolves ShapeScale::extra_small) — the inner counterpart of OUTER_RADIUS.
OUTER_RADIUS
Unthemed-fallback outer corner radius (a theme resolves ShapeScale::large) — the outer a caller with no theme in hand passes member_radii.

Functions§

ButtonGroup
PascalCase alias for button_group, matching the container view-fn vocabulary.
button_group
Create a single-select connected group with the given member labels, the currently-selected index, and an on_select callback fired (with the tapped member’s index) on release inside a member.
button_group_action
Create a labelled group member.
button_group_actions
Create a group from explicit ButtonGroupActions — the full port of M3EButtonGroup, defaulting to ButtonGroupType::Standard and ButtonGroupOverflow::Scroll exactly like the reference.
button_group_icon_action
Create an icon-only group member; icon is called once per rebuild of the member (see [IconFactory]’s note) and may return any view, which is erased here: button_group_icon_action(|| icon(icons::STAR)).
member_radii
The per-corner radii for member index of a count-member connected group: outer corners (the group’s ends) get outer, inner (adjacent-member) corners get inner — M3EToggleButtonGroupTheme.connectedRadiusFor (m3e_toggle_button_group_theme.dart:112).
resolve_spacing
The gap between two adjacent members, in logical px: an explicit ButtonGroupView::spacing wins, else the type’s token gap under the density. See the module docs’ resolved-gap table (and why the connected row is density-independent).
squish_sizes
The per-member main extents after the neighbor-squish press animation — M3ERenderButtonGroup._applySquishSizes + _shrinkNeighborsForSquish (..._render.dart:245/:273).
visible_count_for_overflow
How many members fit before the overflow trigger — M3EButtonGroupOverflowController.computeVisibleCountForMenu (m3e_button_group_overflow_controller.dart:61).