Expand description
The Material 3 Expressive Switch toggle: a controlled component
reporting a requested on/off value, painted as a 52×32dp track with a
spring-driven thumb (16dp unselected / 24dp selected, 32dp while pressed)
and the shared super::state_layer interaction overlay.
Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
lib/components/switch_control/m3e_switch_control.dart +
styles/m3e_switch_theme.dart (retrieved 2026-08-19).
Upstream: https://github.com/paadevelopments/material_3_expressive
switch produces a SwitchView carrying the current checked value
and an on_toggle closure — a controlled component mirroring
frust::Checkbox: it fires on_toggle(state, !checked) on release
inside its bounds and never flips its own checked field; the app
mutates its state and the next rebuild feeds the confirmed value back
in.
§Two independent springs, not one
The reference drives the thumb’s slide and its size off two separate
SingleMotionControllers (m3e_switch_control.dart:60-61), both built on
MaterialSpringMotion.expressiveSpatialDefault (stiffness 380,
tmp/motor-1.1.0/lib/src/motion.dart:610-616) but each copyWith-ing a
different damping ratio: position at 0.55 (a visible overshoot “snap”)
and size at 0.7 (a more directly-settling follow) —
m3e_switch_control.dart:66-76. [POSITION_SPRING]/[SIZE_SPRING]
transcribe those two specs exactly. Both are driven in-widget during
Widget::paint via AnimationController::fling, re-requested with
PaintCtx::request_frame while either is in flight (the crate’s shared
advance-during-paint contract — see docs/CODE_STANDARDS.md’s Theming &
Animation Conventions). rebuild only ever updates the confirmed
checked value; both flings start lazily the next time paint observes
checked disagreeing with anim_target.
Motor’s damping parameter feeds SpringDescription.withDurationAndBounce-style
construction as a damping ratio (motion.dart:678-690: ratio: damping),
the same ζ semantics frust::SpringDesc::damping_ratio uses — so these two
constants carry over as a direct (mass, stiffness, damping_ratio) triple
with no unit conversion.
§Thumb travel and press-grow
The thumb’s bounding box is computed in closed form
(m3e_switch_control.dart:132 padding + :171-187’s _buildThumb,
collapsed from Flutter’s nested LayoutBuilder-in-padding-with-bleed
shape into one pair of expressions — the arithmetic is unchanged, only
not re-derived from a separate constraints pass): [thumb_origin]. Both
the position spring’s value() (used for on-axis travel) and the size
spring’s value() (used for the unselected↔selected diameter blend) are
read unclamped — an under-damped spring’s overshoot past its target
is real, intended motion (see AnimationController’s Overshoot docs),
and the reference itself never clamps either read in this formula. A
press instantly overrides the diameter to [THUMB_PRESSED] regardless of
the size spring’s own value (m3e_switch_control.dart:171-173) — this is
not spring-driven; it snaps the instant pressed flips, same as the
pre-rework behavior.
§Icons: value-selected, size-faded
SwitchView::selected_icon/SwitchView::unselected_icon each take an
optional frust::IconSource, painted inside the thumb
(m3e_switch_control.dart:233-258). The reference does not cross-fade
between the two icons — it picks one icon from the confirmed
checked boolean (not the animated position), then fades/scales that
icon by the size spring’s clamped [0, 1] value
(:242: t = _sizeCtrl.value.clamp(0.0, 1.0); :243-246: opacity: t,
scale: 0.5 + 0.5 * t). One consequence, transcribed faithfully rather
than “fixed”: toggling off shows the unselected icon (chosen the instant
checked flips) fading out as the thumb shrinks (t falling from ~1 to
0), not fading in — the reference’s own shape, not a symmetric two-icon
cross-fade.
§Track/thumb/outline/icon/state-layer colors
[SwitchColors] plus [track_color]/[thumb_color]/[outline_color]/
[icon_color]/[state_layer_color] transcribe
M3ESwitchTheme’s five color methods exactly
(m3e_switch_theme.dart:76-123), including that icon_color/
state_layer_color take no enabled branch in the source — an
asymmetry preserved here rather than “corrected”. An off-track outline
ring (m3e_switch_theme.dart:103-113’s outlineColor,
m3e_switch_control.dart:140-148’s Border.all) paints only while
!checked, at [BORDER_WIDTH].
Porting decision — continuous color, not the reference’s separate
crossfade. The reference re-paints track/thumb/outline color through an
outer AnimatedContainer’s own implicit ~150ms linear duration crossfade
(m3e_switch_control.dart:129, M3EMotion.short3), a third animation
channel independent of the two springs above. This port instead lerps
each color pair by the position spring’s value_clamped() — one fewer
animation channel, and no harsher a transition than the reference’s own
short (150ms) one — the same continuous-color idiom the pre-rework
version of this module used.
§No drag-to-toggle
The reference wires only a tap (M3ETappable(onTap: ...),
m3e_switch_control.dart:121) — no pan/drag gesture anywhere in the
component. None is added here either.
§Haptics: wired, firing None
M3ETappable always fires a haptic on tap
(tmp/material_3_expressive/lib/foundations/m3e_tappable.dart:187-198’s
_wrapTap/_fireHaptic), but M3ESwitch never overrides M3ETappable’s
haptic field, so it fires at that field’s own default,
M3EHapticFeedback.none — a documented no-op
(m3e_haptics.dart’s trigger none case). This port matches that
exactly: super::interaction::MaterialHaptics::fire is called with
super::interaction::HapticSignal::None on every confirmed toggle,
same call site the reference’s _fireHaptic occupies (immediately before
the tap’s own callback), producing no audible/tactile feedback today —
the hook is live for a future revision that wants to override it, exactly
as M3ETappable.haptic is for the reference.
§enabled, not a nullable callback
The reference represents “disabled” as onChanged == null
(m3e_switch_control.dart:63’s _enabled getter). This crate’s
controlled-component contract always takes a concrete on_toggle
closure, so SwitchView::enabled is an explicit flag instead — same
effect (gates interaction and switches the disabled color table), a
different shape to fit this framework’s callback contract.
focusNode/autofocus/semanticLabel are Dart-only parameters
(m3e_switch_control.dart:16-26) with no port here — none of the springs/
icons/colors/haptics rework above touches focus routing or semantics
labeling, so this stays out of scope for this pass.
Structs§
- Switch
View - A declarative M3E switch. See the module docs.
- Switch
Widget - The retained widget for a
SwitchView.