Skip to main content

Module switch

Module switch 

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

SwitchView
A declarative M3E switch. See the module docs.
SwitchWidget
The retained widget for a SwitchView.

Functions§

Switch
PascalCase alias for switch.
switch
Create a switch reflecting checked that fires on_toggle(state, !checked) on release inside its bounds.