Expand description
The Material 3 Expressive toggle button: a controlled on/off control that morphs its container between a round (unchecked) and a square (checked) shape, in any of the five button style variants.
toggle_button builds a filled toggle button; outlined_toggle_button,
tonal_toggle_button, elevated_toggle_button and
text_toggle_button are the reference’s other four named constructors.
Every one is a controlled component (see docs/CODE_STANDARDS.md’s
Interaction Semantics): a tap reports the requested value through
on_checked_change and leaves checked untouched — the app feeds the
confirmed value back on the next rebuild, exactly like
crate::switch/frust::Checkbox.
§Variants × checked (_resolvedForegroundColor/_resolvedBackgroundColor,
m3e_toggle_button_style.dart:118/:134)
| Variant | Content unchecked | Content checked | Container unchecked | Container checked |
|---|---|---|---|---|
ButtonVariant::Filled (default) | on_surface_variant | on_primary | surface_container_highest | primary |
ButtonVariant::Elevated | primary | on_primary | surface_container_low | primary |
ButtonVariant::Tonal | on_surface_variant | on_secondary_container | surface_container_highest | secondary_container |
ButtonVariant::Outlined | on_surface | on_secondary_container | transparent | secondary_container |
ButtonVariant::Text | on_surface | primary | transparent | transparent |
Unlike the plain crate::button, an unchecked Filled/Tonal toggle
still paints an opaque container (surface_container_highest) — the “off”
pill look every M3E toggle-button row uses. The outline hairline paints
only for ButtonVariant::Outlined, both checked and unchecked
(_sideProperty), reusing colors.outline exactly as the plain button
does. Disabled colors are checked-independent, reusing the plain button’s
38%/12% on_surface roles (crate::interaction::DISABLED_CONTENT_OPACITY/
crate::interaction::DISABLED_CONTAINER_OPACITY — M3EButtonConstants’s
disabled alphas are the same three constants the plain button cites).
Elevation is not checked-aware (_buttonTheme.elevation reads plain
WidgetStates, never adding .selected the way the color/side resolvers
do) — reusing the plain button’s own (variant, enabled, pressed, hovered)
table verbatim (duplicated in [elevation_dp] since button::core is
private; see this module’s Porting decision above).
§Sizes: three of the five (m3e_button_theme.dart:76’s rows)
| Size | Height | Padding (labeled) | Icon | Gap | Label role |
|---|---|---|---|---|---|
ToggleButtonSize::Sm (default) | 40 | 16 | 20 | 8 | label_medium |
ToggleButtonSize::Md | 56 | 24 | 24 | 8 | label_large |
ToggleButtonSize::Lg | 96 | 48 | 32 | 12 | title_medium |
Scoped to sm/md/lg (the reference’s xs/xl rows are not ported).
An icon-only toggle (no effective label) halves its horizontal padding
(_buildWidget’s hPad = _hasLabel ? m.hPadding : m.hPadding / 2) — and,
also unlike the plain button, a toggle button has no minimum-width
floor: the reference’s _buildButtonStyle sets minimumSize: Size(0, height), never touching M3EButtonTheme.minWidthFloor (that
constant is read only by the plain button’s own ButtonStyle,
m3e_button_style.dart:11) — transcribed faithfully, not “fixed”.
§Content wider than the box: covered, never re-fitted
A toggle button shapes its label once, at its natural width, and a box
too small for the resulting content row centers and clips it rather than
ellipsizing it to fit. That is the reference’s own answer, not a
simplification: _fitContentToConstraints
(m3e_toggle_button_content.dart:95-:111) hands a bounded constraint to
FittedBox(fit: BoxFit.none, clipBehavior: Clip.hardEdge), which lays the
natural row out unbounded, applies no scale, centers it
(Alignment.center) and hard-clips the overflow. The one ellipsis the
reference’s label style carries (m3e_base_button_state.dart:177) can
therefore never fire on this path — nothing ever hands the label a bounded
width — and the label merges only maxLines: 1, softWrap: false
(_buildLabelText, :194). The plain crate::button is the genuine
contrast, and keeps its own ellipsize-to-fit: its label sits in a
Flexible inside the content row (m3e_button_state.dart:28-:35), so it
really is handed the width left over.
This is what a crate::button_group member’s press squish looks like
in the reference and now here: the padding compresses first (the row is
centered in a shrinking box), then the box edge slides over stationary
glyphs — never a label re-flowing to a new ellipsis every frame, and never
a truncation left behind once the box comes back. Two consequences worth
naming: [LabelRun] caches on style alone (no width key, so a squish
re-shapes nothing), and content_x is allowed to go negative so the
overflow is split evenly off both ends.
§Checked shape morph: round ↔ square, one spring
Unchecked rests at the round family radius (height / 2, a pill);
checked rests at the square family radius —
ToggleButtonSize::square_radius, the exact per-size token the plain
button’s own square shape resolves
(theme.shape.medium/large/extra_large, 12/16/28 unthemed). Pressed
and hovered each override both of those with their own per-size token
(ToggleButtonSize::pressed_radius/ToggleButtonSize::hovered_radius,
same tables as the plain button), in pressed > hovered > checked > rest
precedence — m3e_toggle_button_shape.dart’s
_resolveStandaloneTargetRadius. All four corners move together for a
standalone toggle button; see Group-connection corner asymmetry below
for why the driver still carries four independent corners rather than one
scalar.
The spring is [SHAPE_SPRING] — stiffness 380, damping ratio 0.55, the
same expressiveSpatialPress preset
(crate::tokens::MaterialSpring::EXPRESSIVE_SPATIAL_PRESS) the plain
button’s press morph flings along
(_M3EToggleButtonState.effectiveMotion’s
M3EButtonMotion.expressiveSpatialPress default,
m3e_toggle_button_state.dart:61-:62). [ShapeMotion] carries the exact
retarget/continuity contract crate::button’s
RadiusPaddingMotion documents (mount-frame snap, a
[RETARGET_TOLERANCE]-px dead band, mid-flight retarget continuity from
the value being painted right now rather than a discontinuous
progress-flip, and a 1.5× [OVERSHOOT_LIMIT]) — this module’s own tests
re-pin all of it directly, since that type is private to a sibling module
this task must not touch.
§Group-connection corner asymmetry
ToggleButtonView::group_connected/ToggleButtonView::first_in_group/
ToggleButtonView::last_in_group are plumbing for the upcoming
button-group rework, exercised directly here. When connected, the two
outer corners (the group-facing side of a first/last member) stay pinned
at the pill radius (height / 2) in every state; the two inner
(adjacent-member) corners instead resolve per state —
[CONNECTED_INNER_RADIUS] (6dp, unthemed) resting,
[CONNECTED_PRESSED_INNER_RADIUS] (2dp, unthemed) pressed, the per-size
ToggleButtonSize::hovered_radius token hovered — mirroring
m3e_toggle_button_shape.dart’s _resolveConnectedTargetRadius. A
checked, connected, non-pressed/non-hovered member is a special case:
all four corners go fully round (checkedConnectedShape), not the
per-size square token a standalone checked member uses — the reference’s
own asymmetry between the standalone and connected checked shapes,
transcribed rather than “corrected”. is_first_in_group/is_last_in_group
resolve independently, so a single-member group (true/true) is all
outer corners, matching crate::button_group’s own single-member
convention. Painted with frust::authoring::CornerRadii via
PaintScene::fill_rounded_rect_radii rather than
crate::button_group’s own kurbo-RoundedRect-plus-fill_path
route — both reach the same pixels; this module takes the newer
per-corner-native paint call directly. This framework has no
Directionality/RTL layout yet, so — like button_group’s own
member_radii — “first”/“last” map onto “left”/“right” unconditionally,
with no mirrored-in-RTL case to port.
§Icon/label swap on checked
ToggleButtonView::icon/ToggleButtonView::checked_icon and
ToggleButtonView::label/ToggleButtonView::checked_label each fall
back to their unchecked sibling when the checked-state one is unset
(_effectiveIcon/_effectiveLabel, m3e_toggle_button_state.dart:28-
:48) — all four slots are optional, so a toggle button may be icon-only,
label-only, both, or (checked-state-only) neither. Porting decision:
the reference cross-fades and horizontally slides between the unchecked
and checked label (_buildAnimatedLabelSlot, a second, independent
spring) when exactly one of icon/checkedIcon is paired with exactly
one of label/checkedLabel set on the opposite side
(_hasDistinctLabelStates). That second animation channel is out of scope
here: the effective icon/label swap immediately on the checked flip the
same frame it is confirmed, with no cross-fade — a reasonable v1 cut this
module’s tests pin directly rather than leave undocumented.
§Long-press: fires, but on the next pointer event, not immediately
ToggleButtonView::on_long_press fires once a capture has been held past
[LONG_PRESS_MS] (500ms — same value and citation
frust_widgets::gesture’s GestureDetector uses: community-approximate,
Android’s ViewConfiguration defaults to ~400-500ms, iOS’s
UILongPressGestureRecognizer.minimumPressDuration to 0.5s). paint is
the only pass carrying a clock in this framework, so it latches an
elapsed flag the same way GestureDetector does — but that widget then
fires immediately, with no further pointer event, via
frust_core::mark_pending_result_flush forcing an extra rebuild pass. That
function is not part of frust::authoring’s re-exported surface (a
design-system plugin depends on the frust facade alone, never
frust-core directly in production — docs/PLUGINS_CODE_STANDARDS.md’s
Plugin Conventions), so it is unreachable here. This widget instead fires
on the next pointer event that arrives after the threshold — the same
move-arrival delivery GestureDetector itself falls back to when no
long-press handler is wired for its own on_hold_progress-only case — with
an Up as the final fallback if no Move ever arrives. A long-press and
the checked toggle resolve as mutually exclusive outcomes of the same
press, exactly like GestureDetector’s own tap/long-press arbitration: an
Up that already crossed the threshold fires the long-press, never the
toggle. Movement past frust::input::TOUCH_SLOP from the initiating
Down cancels the long-press candidacy outright (a drag, not a hold).
§Haptics, focus ring, decoration/gradient seam
ToggleButtonView::haptic defaults to HapticSignal::None, fired
(when set) on the same up-inside edge that reports the checked toggle,
through MaterialHaptics::fire — the plain button’s own convention
(m3e_toggle_button_state.dart:120’s M3EHaptics.trigger call site).
Neither a focus ring (m3e_focus_ring.dart, which the plain button ports)
nor the M3EToggleButtonDecoration gradient/override seam (the plain
button’s still-unfilled ButtonDecoration seam) is ported in this v1 —
out of this task’s acceptance scope; PaintCtx::has_focus/is_hovered
still drive the state-layer overlay correctly, only the extra ring stroke
is deferred.
§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§
- Toggle
Button View - A declarative M3 Expressive toggle button. See the module docs.
- Toggle
Button Widget - The retained widget for a
ToggleButtonView. See the module docs.
Enums§
- Toggle
Button Size - The toggle button’s size tier — a three-row subset of the reference’s
M3EButtonSize(sm/md/lgonly). See the module docs’ Sizes table.
Functions§
- Toggle
Button - PascalCase alias for
toggle_button, matching this catalog’s view-fn vocabulary (Button,ButtonGroup, …). - elevated_
toggle_ button - Create an elevated toggle button —
M3EToggleButton.elevated. - filled_
toggle_ button - Create a filled toggle button —
toggle_buttonunder the name the reference’sM3EToggleButton.filledconstructor uses. - outlined_
toggle_ button - Create an outlined toggle button —
M3EToggleButton.outlined. - text_
toggle_ button - Create a text toggle button —
M3EToggleButton.text. - toggle_
button - Create a filled (highest-emphasis) toggle button reporting the requested
checked value through
on_checked_changeon release inside its bounds — the reference’s defaultM3EToggleButton/M3EToggleButton.filled. - tonal_
toggle_ button - Create a tonal toggle button —
M3EToggleButton.tonal.