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
| Axis | Values | Reference |
|---|---|---|
ButtonGroupType | Standard (gaps) / Connected (shared edges) | M3EButtonGroupType, m3e_toggle_button_group_enums.dart:9 |
ButtonGroupDensity | Regular / Compact (75% of the token gap, floored to whole dp) | M3EButtonGroupDensity, :21 |
ButtonGroupDirection | Horizontal / Vertical | M3EButtonGroup.direction (Axis) |
The resolved member gap (resolve_spacing) is:
| Type | Regular | Compact |
|---|---|---|
Standard | 8dp (standardSpacing) | 6dp (floor(8 × 0.75)) |
Connected | 2dp (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:
ButtonGroupView::selected_indices— multi-select; membership in the set.ButtonGroupView::on_selected_index_changedorButtonGroupView::selected_index— single-select;selected == Some(i).- otherwise the per-action
ButtonGroupAction::checkedflag (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
ButtonGroupOverflow | Behaviour |
|---|---|
None | Lay 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). |
BottomSheet | Show 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. |
Popup | The 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§
- Button
Group Action - A declarative description of one member —
M3EButtonGroupAction(models/m3e_button_group_action.dart:12). - Button
Group View - A declarative M3 Expressive button group. See the module docs.
- Button
Group Widget - The retained widget for a
ButtonGroupView. See the module docs.
Enums§
- Button
Group Density - Spacing compactness between adjacent members —
M3EButtonGroupDensity(m3e_toggle_button_group_enums.dart:21). - Button
Group Direction - The group’s primary layout axis —
M3EButtonGroup.direction(Flutter’sAxis). - Button
Group Overflow - 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). - Button
Group Type - 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) — theinnercounterpart ofOUTER_RADIUS. - OUTER_
RADIUS - Unthemed-fallback outer corner radius (a theme resolves
ShapeScale::large) — theoutera caller with no theme in hand passesmember_radii.
Functions§
- Button
Group - 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-selectedindex, and anon_selectcallback 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 ofM3EButtonGroup, defaulting toButtonGroupType::StandardandButtonGroupOverflow::Scrollexactly like the reference. - button_
group_ icon_ action - Create an icon-only group member;
iconis 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
indexof acount-member connected group: outer corners (the group’s ends) getouter, inner (adjacent-member) corners getinner—M3EToggleButtonGroupTheme.connectedRadiusFor(m3e_toggle_button_group_theme.dart:112). - resolve_
spacing - The gap between two adjacent members, in logical px: an explicit
ButtonGroupView::spacingwins, 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).