Skip to main content

Module toggle_button

Module toggle_button 

Source
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)

VariantContent uncheckedContent checkedContainer uncheckedContainer checked
ButtonVariant::Filled (default)on_surface_varianton_primarysurface_container_highestprimary
ButtonVariant::Elevatedprimaryon_primarysurface_container_lowprimary
ButtonVariant::Tonalon_surface_varianton_secondary_containersurface_container_highestsecondary_container
ButtonVariant::Outlinedon_surfaceon_secondary_containertransparentsecondary_container
ButtonVariant::Texton_surfaceprimarytransparenttransparent

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)

SizeHeightPadding (labeled)IconGapLabel role
ToggleButtonSize::Sm (default)4016208label_medium
ToggleButtonSize::Md5624248label_large
ToggleButtonSize::Lg96483212title_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§

ToggleButtonView
A declarative M3 Expressive toggle button. See the module docs.
ToggleButtonWidget
The retained widget for a ToggleButtonView. See the module docs.

Enums§

ToggleButtonSize
The toggle button’s size tier — a three-row subset of the reference’s M3EButtonSize (sm/md/lg only). See the module docs’ Sizes table.

Functions§

ToggleButton
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_button under the name the reference’s M3EToggleButton.filled constructor 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_change on release inside its bounds — the reference’s default M3EToggleButton/M3EToggleButton.filled.
tonal_toggle_button
Create a tonal toggle button — M3EToggleButton.tonal.