Skip to main content

tessera_components/
switch.rs

1//! An interactive toggle switch component.
2//!
3//! ## Usage
4//!
5//! Use to control a boolean on/off state.
6use tessera_foundation::gesture::TapRecognizer;
7use tessera_ui::{
8    AxisConstraint, CallbackWith, Color, ComputedData, Constraint, Dp, FocusRequester,
9    LayoutResult, MeasurementError, Modifier, Px, PxPosition, PxSize, RenderSlot, State,
10    accesskit::{Role, Toggled},
11    current_frame_nanos,
12    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
13    modifier::ModifierCapabilityExt as _,
14    provide_context, receive_frame_nanos, remember, tessera, use_context,
15};
16
17use crate::{
18    alignment::Alignment,
19    animation::material_motion::{Scalar, Spec},
20    boxed::boxed,
21    icon::IconSize,
22    modifier::{
23        DisabledToggleSemantics, InteractionState, InteractionVisualState, ModifierExt,
24        PointerEventContext, SemanticsArgs, ToggleableArgs,
25    },
26    ripple_state::{RippleSpec, RippleState},
27    shape_def::Shape,
28    surface::{SurfaceStyle, surface},
29    theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme},
30};
31
32use motion::Geometry;
33
34mod motion;
35
36#[cfg(test)]
37const THUMB_TEST_TAG: &str = "__switch_thumb";
38
39/// Material Design 3 defaults for [`switch`].
40pub struct SwitchDefaults;
41
42struct SwitchColorInputs {
43    track_color: Color,
44    track_checked_color: Color,
45    track_outline_color: Color,
46    thumb_color: Color,
47    thumb_checked_color: Color,
48    thumb_checked_icon_color: Color,
49    thumb_icon_color: Color,
50}
51
52struct SwitchStateFlags {
53    checked: bool,
54    enabled: bool,
55}
56
57impl SwitchDefaults {
58    /// Default track width.
59    pub const WIDTH: Dp = Dp(52.0);
60    /// Default track height.
61    pub const HEIGHT: Dp = Dp(32.0);
62    /// Default state layer size (unbounded ripple/hover target around the
63    /// thumb).
64    pub const STATE_LAYER_SIZE: Dp = Dp(40.0);
65    /// Thumb diameter when checked or when it contains content.
66    pub const THUMB_DIAMETER: Dp = Dp(24.0);
67    /// Thumb diameter when unchecked and it has no content.
68    pub const UNCHECKED_THUMB_DIAMETER: Dp = Dp(16.0);
69    /// Thumb diameter when pressed.
70    pub const PRESSED_THUMB_DIAMETER: Dp = Dp(28.0);
71    /// Default track outline width.
72    pub const TRACK_OUTLINE_WIDTH: Dp = Dp(2.0);
73    /// Default size of icons rendered inside the thumb.
74    pub const ICON_SIZE: Dp = Dp(16.0);
75
76    /// Resolves effective colors for the current state.
77    fn resolve_colors(
78        inputs: SwitchColorInputs,
79        scheme: &MaterialColorScheme,
80        flags: SwitchStateFlags,
81    ) -> SwitchResolvedColors {
82        let mut track_color = if flags.checked {
83            inputs.track_checked_color
84        } else {
85            inputs.track_color
86        };
87        let mut track_outline_color = if flags.checked {
88            Color::TRANSPARENT
89        } else {
90            inputs.track_outline_color
91        };
92        let mut thumb_color = if flags.checked {
93            inputs.thumb_checked_color
94        } else {
95            inputs.thumb_color
96        };
97        let mut icon_color = if flags.checked {
98            inputs.thumb_checked_icon_color
99        } else {
100            inputs.thumb_icon_color
101        };
102
103        if !flags.enabled {
104            let disabled_container_alpha = MaterialAlpha::DISABLED_CONTAINER;
105            let disabled_content_alpha = MaterialAlpha::DISABLED_CONTENT;
106
107            if flags.checked {
108                thumb_color = scheme.surface;
109                icon_color = scheme
110                    .surface
111                    .blend_over(scheme.on_surface, disabled_content_alpha);
112                track_color = scheme
113                    .surface
114                    .blend_over(scheme.on_surface, disabled_container_alpha);
115                track_outline_color = Color::TRANSPARENT;
116            } else {
117                thumb_color = scheme
118                    .surface
119                    .blend_over(scheme.on_surface, disabled_content_alpha);
120                icon_color = scheme
121                    .surface
122                    .blend_over(scheme.surface_container_highest, disabled_content_alpha);
123                track_color = scheme
124                    .surface
125                    .blend_over(scheme.surface_container_highest, disabled_container_alpha);
126                track_outline_color = scheme
127                    .surface
128                    .blend_over(scheme.on_surface, disabled_container_alpha);
129            }
130        }
131
132        SwitchResolvedColors {
133            track_color,
134            track_outline_color,
135            thumb_color,
136            icon_color,
137        }
138    }
139}
140
141struct SwitchResolvedColors {
142    track_color: Color,
143    track_outline_color: Color,
144    thumb_color: Color,
145    icon_color: Color,
146}
147
148#[derive(Clone, PartialEq)]
149struct SwitchLayout {
150    track_width: Dp,
151    track_height: Dp,
152    thumb_diameter: Dp,
153    thumb_offset: Dp,
154}
155
156impl tessera_ui::LayoutModifierNode for SwitchLayout {
157    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
158        let mut result = LayoutResult::default();
159        let children = input.children();
160        let track = children[0];
161        let state_layer = children[1];
162        let thumb = children[2];
163        let thumb_constraint = Constraint::NONE;
164        let track_size = track.measure(input, &thumb_constraint)?;
165        let state_layer_size = state_layer.measure(input, &thumb_constraint)?;
166        let thumb_size = thumb.measure(input, &thumb_constraint)?;
167
168        let self_width_px = track_size
169            .width
170            .max(state_layer_size.width)
171            .max(thumb_size.width);
172        let self_height_px = track_size
173            .height
174            .max(state_layer_size.height)
175            .max(thumb_size.height);
176        let track_origin_x = (self_width_px.0 - track_size.width.0) / 2;
177        let track_origin_y = (self_height_px.0 - track_size.height.0) / 2;
178
179        let thumb_x = self.thumb_offset.to_px();
180
181        let thumb_center_x = track_origin_x + thumb_x.0 + thumb_size.width.0 / 2;
182        let thumb_center_y = track_origin_y + track_size.height.0 / 2;
183        let state_layer_x = thumb_center_x - state_layer_size.width.0 / 2;
184        let state_layer_y = thumb_center_y - state_layer_size.height.0 / 2;
185
186        result.place_child(
187            track,
188            PxPosition::new(Px(track_origin_x), Px(track_origin_y)),
189        );
190        result.place_child(
191            thumb,
192            PxPosition::new(
193                Px(track_origin_x + thumb_x.0),
194                Px(track_origin_y + (track_size.height.0 - thumb_size.height.0) / 2),
195            ),
196        );
197        result.place_child(
198            state_layer,
199            PxPosition::new(Px(state_layer_x), Px(state_layer_y)),
200        );
201
202        Ok(result.with_size(ComputedData {
203            width: self_width_px,
204            height: self_height_px,
205        }))
206    }
207
208    fn measure_eq(&self, other: &Self) -> bool {
209        self.track_width == other.track_width
210            && self.track_height == other.track_height
211            && self.thumb_diameter == other.thumb_diameter
212    }
213
214    fn placement_eq(&self, other: &Self) -> bool {
215        self.measure_eq(other) && self.thumb_offset == other.thumb_offset
216    }
217
218    fn place_children(
219        &self,
220        input: &PlacementScope<'_>,
221        _inner: &PlacementResult,
222    ) -> Option<PlacementResult> {
223        let mut result = LayoutResult::default();
224        let children = input.children();
225        if children.len() < 3 {
226            return Some(result.into_placement_result());
227        }
228        let track = children[0];
229        let state_layer = children[1];
230        let thumb = children[2];
231        let track_size = track.size();
232        let state_layer_size = state_layer.size();
233        let thumb_size = thumb.size();
234
235        let self_width_px = input.size().width;
236        let self_height_px = input.size().height;
237        let track_origin_x = (self_width_px.0 - track_size.width.0) / 2;
238        let track_origin_y = (self_height_px.0 - track_size.height.0) / 2;
239
240        let thumb_x = self.thumb_offset.to_px();
241
242        let thumb_center_x = track_origin_x + thumb_x.0 + thumb_size.width.0 / 2;
243        let thumb_center_y = track_origin_y + track_size.height.0 / 2;
244        let state_layer_x = thumb_center_x - state_layer_size.width.0 / 2;
245        let state_layer_y = thumb_center_y - state_layer_size.height.0 / 2;
246
247        result.place_child(
248            track,
249            PxPosition::new(Px(track_origin_x), Px(track_origin_y)),
250        );
251        result.place_child(
252            thumb,
253            PxPosition::new(
254                Px(track_origin_x + thumb_x.0),
255                Px(track_origin_y + (track_size.height.0 - thumb_size.height.0) / 2),
256            ),
257        );
258        result.place_child(
259            state_layer,
260            PxPosition::new(Px(state_layer_x), Px(state_layer_y)),
261        );
262        Some(result.into_placement_result())
263    }
264}
265
266#[derive(Clone, PartialEq)]
267struct SwitchThumbLayout {
268    size: Px,
269}
270
271impl tessera_ui::LayoutModifierNode for SwitchThumbLayout {
272    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
273        let mut result = LayoutResult::default();
274        let constraint = Constraint::new(
275            AxisConstraint::exact(self.size),
276            AxisConstraint::exact(self.size),
277        );
278
279        for child in input.children() {
280            let _ = child.measure(input, &constraint)?;
281            result.place_child(child, PxPosition::ZERO);
282        }
283
284        Ok(result.with_size(ComputedData {
285            width: self.size,
286            height: self.size,
287        }))
288    }
289}
290
291/// Controller for the `switch` component.
292pub struct SwitchController {
293    checked: bool,
294    progress: Scalar,
295    snap_revision: u64,
296}
297
298impl SwitchController {
299    /// Creates a new controller with the given initial value.
300    pub fn new(initial_state: bool) -> Self {
301        Self {
302            checked: initial_state,
303            progress: Scalar::new(if initial_state { 1.0 } else { 0.0 }),
304            snap_revision: 0,
305        }
306    }
307
308    /// Returns whether the switch is currently checked.
309    pub fn is_checked(&self) -> bool {
310        self.checked
311    }
312
313    /// Sets the checked state immediately, finishing any ongoing transition.
314    pub fn set_checked(&mut self, checked: bool) {
315        self.checked = checked;
316        self.progress.snap(if checked { 1.0 } else { 0.0 });
317        self.snap_revision = self.snap_revision.wrapping_add(1);
318    }
319
320    /// Toggles the switch with an interruptible transition.
321    pub fn toggle(&mut self) {
322        self.toggle_at(current_frame_nanos());
323    }
324
325    fn toggle_at(&mut self, frame_nanos: u64) {
326        self.checked = !self.checked;
327        self.progress.retarget(
328            if self.checked { 1.0 } else { 0.0 },
329            Spec::FastSpatial,
330            frame_nanos,
331        );
332    }
333
334    /// Returns the current selection progress, clamped to 0.0..1.0.
335    pub fn animation_progress(&self) -> f32 {
336        self.progress.value.clamp(0.0, 1.0) as f32
337    }
338
339    /// Returns whether the selection transition is still settling.
340    pub fn is_animating(&self) -> bool {
341        self.progress.is_animating()
342    }
343
344    fn update_progress(&mut self, frame_nanos: u64) {
345        self.progress.advance(frame_nanos);
346    }
347}
348
349impl Default for SwitchController {
350    fn default() -> Self {
351        Self::new(false)
352    }
353}
354
355/// # switch
356///
357/// Render a Material switch for boolean on/off input in settings or forms.
358///
359/// ## Usage
360///
361/// Use when you want a standard on/off switch without custom thumb content.
362///
363/// ## Parameters
364///
365/// - `modifier` — optional modifier chain applied to the switch subtree.
366/// - `focus_requester` — optional handle targeting the switch for focus
367///   requests.
368/// - `on_toggle` — optional callback invoked when the switch toggles; omit for
369///   a noninteractive state display that retains its accessibility value.
370/// - `enabled` — optional enabled state; defaults to `true`.
371/// - `presentation_state` — optional visual interaction state; live input and
372///   disabled state take precedence.
373/// - `checked` — externally controlled checked state; omit to use the
374///   controller.
375/// - `width` — optional track width override.
376/// - `height` — optional track height override.
377/// - `track_color` — optional track color when unchecked.
378/// - `track_checked_color` — optional track color when checked.
379/// - `track_outline_color` — optional track outline color when unchecked.
380/// - `track_outline_width` — optional outline width.
381/// - `thumb_color` — optional thumb color when unchecked.
382/// - `thumb_checked_color` — optional thumb color when checked.
383/// - `thumb_checked_icon_color` — optional icon color when checked.
384/// - `thumb_icon_color` — optional icon color when unchecked.
385/// - `accessibility_label` — optional accessibility label.
386/// - `accessibility_description` — optional accessibility description.
387/// - `controller` — optional external controller.
388/// - `child` — optional content rendered at the thumb center; icons default to
389///   16dp and inherit the current thumb icon color.
390///
391/// ## Examples
392///
393/// ```
394/// # use tessera_ui::tessera;
395/// # #[tessera]
396/// # fn component() {
397/// use tessera_components::switch::{SwitchController, switch};
398/// use tessera_ui::remember;
399/// # use tessera_components::theme::{MaterialTheme, material_theme};
400/// material_theme()
401///     .theme(|| MaterialTheme::default())
402///     .child(|| {
403///         let controller = remember(|| {
404///             let mut controller = SwitchController::new(false);
405///             controller.toggle();
406///             assert!(controller.is_checked());
407///             controller
408///         });
409///         switch().controller(controller).on_toggle(|_| {});
410///     });
411/// # }
412/// # tessera_ui::testing::layout_test(component).run();
413/// ```
414#[tessera]
415pub fn switch(
416    modifier: Option<Modifier>,
417    focus_requester: Option<FocusRequester>,
418    on_toggle: Option<CallbackWith<bool, ()>>,
419    enabled: Option<bool>,
420    presentation_state: Option<InteractionVisualState>,
421    checked: Option<bool>,
422    width: Option<Dp>,
423    height: Option<Dp>,
424    track_color: Option<Color>,
425    track_checked_color: Option<Color>,
426    track_outline_color: Option<Color>,
427    track_outline_width: Option<Dp>,
428    thumb_color: Option<Color>,
429    thumb_checked_color: Option<Color>,
430    thumb_checked_icon_color: Option<Color>,
431    thumb_icon_color: Option<Color>,
432    #[prop(into)] accessibility_label: Option<String>,
433    #[prop(into)] accessibility_description: Option<String>,
434    controller: Option<State<SwitchController>>,
435    child: Option<RenderSlot>,
436) {
437    let controller = if let Some(controller) = controller {
438        controller
439    } else {
440        remember(|| SwitchController::new(checked.unwrap_or(false)))
441    };
442    if let Some(checked) = checked
443        && controller.with(|controller| controller.is_checked()) != checked
444    {
445        controller.with_mut(|controller| controller.toggle());
446    }
447    let focus_requester = if let Some(focus_requester) = focus_requester {
448        focus_requester
449    } else {
450        remember(FocusRequester::new).get()
451    };
452    let enabled = enabled.unwrap_or(true);
453    let interactive = enabled && on_toggle.is_some();
454    let interaction_state = if interactive {
455        Some(remember(InteractionState::new))
456    } else {
457        None
458    };
459    let ripple_state = if interactive {
460        Some(remember(RippleState::new))
461    } else {
462        None
463    };
464    let tap_recognizer = if interactive {
465        Some(remember(TapRecognizer::default))
466    } else {
467        None
468    };
469
470    if controller.with(|c| c.is_animating()) {
471        receive_frame_nanos(move |frame_nanos| {
472            let is_animating = controller.with_mut(|controller| {
473                controller.update_progress(frame_nanos);
474                controller.is_animating()
475            });
476            if is_animating {
477                tessera_ui::FrameNanosControl::Continue
478            } else {
479                tessera_ui::FrameNanosControl::Stop
480            }
481        });
482    }
483
484    switch_content()
485        .focus_requester(focus_requester)
486        .modifier_optional(modifier)
487        .on_toggle_optional(on_toggle)
488        .enabled(enabled)
489        .presentation_state_optional(presentation_state)
490        .width_optional(width)
491        .height_optional(height)
492        .track_color_optional(track_color)
493        .track_checked_color_optional(track_checked_color)
494        .track_outline_color_optional(track_outline_color)
495        .track_outline_width_optional(track_outline_width)
496        .thumb_color_optional(thumb_color)
497        .thumb_checked_color_optional(thumb_checked_color)
498        .thumb_checked_icon_color_optional(thumb_checked_icon_color)
499        .thumb_icon_color_optional(thumb_icon_color)
500        .accessibility_label_optional(accessibility_label)
501        .accessibility_description_optional(accessibility_description)
502        .controller(controller)
503        .interaction_state_optional(interaction_state)
504        .ripple_state_optional(ripple_state)
505        .tap_recognizer_optional(tap_recognizer)
506        .child_optional(child);
507}
508
509#[tessera]
510#[allow(clippy::too_many_arguments)]
511fn switch_content(
512    modifier: Option<Modifier>,
513    focus_requester: Option<FocusRequester>,
514    on_toggle: Option<CallbackWith<bool, ()>>,
515    enabled: Option<bool>,
516    presentation_state: Option<InteractionVisualState>,
517    width: Option<Dp>,
518    height: Option<Dp>,
519    track_color: Option<Color>,
520    track_checked_color: Option<Color>,
521    track_outline_color: Option<Color>,
522    track_outline_width: Option<Dp>,
523    thumb_color: Option<Color>,
524    thumb_checked_color: Option<Color>,
525    thumb_checked_icon_color: Option<Color>,
526    thumb_icon_color: Option<Color>,
527    accessibility_label: Option<String>,
528    accessibility_description: Option<String>,
529    controller: Option<State<SwitchController>>,
530    interaction_state: Option<State<InteractionState>>,
531    ripple_state: Option<State<RippleState>>,
532    tap_recognizer: Option<State<TapRecognizer>>,
533    child: Option<RenderSlot>,
534) {
535    let enabled = enabled.unwrap_or(true);
536    let controller = controller.expect("switch_content requires controller to be set");
537    let scheme = use_context::<MaterialTheme>()
538        .expect("MaterialTheme must be provided")
539        .get()
540        .color_scheme;
541    let width = width.unwrap_or(SwitchDefaults::WIDTH);
542    let height = height.unwrap_or(SwitchDefaults::HEIGHT);
543    let track_color = track_color.unwrap_or(scheme.surface_container_highest);
544    let track_checked_color = track_checked_color.unwrap_or(scheme.primary);
545    let track_outline_color = track_outline_color.unwrap_or(scheme.outline);
546    let track_outline_width = track_outline_width.unwrap_or(SwitchDefaults::TRACK_OUTLINE_WIDTH);
547    let visual = interaction_state
548        .map(|state| state.with(|state| state.with_presentation(presentation_state, enabled)))
549        .unwrap_or_else(|| InteractionState::new().with_presentation(presentation_state, enabled));
550    let interacting = visual.is_hovered() || visual.is_focused() || visual.is_pressed();
551    let thumb_color = thumb_color.unwrap_or(if interacting {
552        scheme.on_surface_variant
553    } else {
554        scheme.outline
555    });
556    let thumb_checked_color = thumb_checked_color.unwrap_or(if interacting {
557        scheme.primary_container
558    } else {
559        scheme.on_primary
560    });
561    let thumb_checked_icon_color = thumb_checked_icon_color.unwrap_or(scheme.on_primary_container);
562    let thumb_icon_color = thumb_icon_color.unwrap_or(scheme.surface_container_highest);
563
564    let mut modifier = modifier.unwrap_or_default();
565
566    let interactive = on_toggle.is_some();
567    let checked = controller.with(|c| c.is_checked());
568    if interactive {
569        modifier = modifier.minimum_interactive_component_size();
570        let ripple_spec = RippleSpec {
571            bounded: false,
572            radius: Some(Dp(SwitchDefaults::STATE_LAYER_SIZE.0 / 2.0)),
573        };
574        let ripple_size = PxSize::new(
575            SwitchDefaults::STATE_LAYER_SIZE.to_px(),
576            SwitchDefaults::STATE_LAYER_SIZE.to_px(),
577        );
578        let press_handler = ripple_state.map(|state| {
579            let spec = ripple_spec;
580            let size = ripple_size;
581            move |ctx: PointerEventContext| {
582                state.with_mut(|s| s.start_animation_with_spec(ctx.normalized_pos, size, spec));
583            }
584        });
585        let release_handler = ripple_state
586            .map(|state| move |_ctx: PointerEventContext| state.with_mut(|s| s.release()));
587        let toggle_args = ToggleableArgs {
588            focus_requester,
589            value: checked,
590            on_value_change: CallbackWith::new(move |_| {
591                controller.with_mut(|c| c.toggle());
592                let checked = controller.with(|c| c.is_checked());
593                if let Some(on_toggle) = on_toggle.as_ref() {
594                    on_toggle.call(checked);
595                }
596            }),
597            enabled,
598            role: Some(Role::Switch),
599            label: accessibility_label.clone(),
600            description: accessibility_description.clone(),
601            interaction_state,
602            on_press: press_handler.map(Into::into),
603            on_release: release_handler.map(Into::into),
604            tap_recognizer,
605            ..Default::default()
606        };
607        modifier = if enabled {
608            modifier.toggleable_with(toggle_args)
609        } else {
610            modifier.push_semantics(DisabledToggleSemantics {
611                role: Role::Switch,
612                value: checked,
613                label: accessibility_label,
614                description: accessibility_description,
615            })
616        };
617    } else {
618        modifier = modifier.semantics(SemanticsArgs {
619            role: Some(Role::Switch),
620            toggled: Some(if checked {
621                Toggled::True
622            } else {
623                Toggled::False
624            }),
625            label: accessibility_label,
626            description: accessibility_description,
627            disabled: !enabled,
628            ..Default::default()
629        });
630    }
631
632    let has_thumb_content = child.is_some();
633    let is_pressed = visual.is_pressed();
634    let colors = SwitchDefaults::resolve_colors(
635        SwitchColorInputs {
636            track_color,
637            track_checked_color,
638            track_outline_color,
639            thumb_color,
640            thumb_checked_color,
641            thumb_checked_icon_color,
642            thumb_icon_color,
643        },
644        &scheme,
645        SwitchStateFlags { checked, enabled },
646    );
647
648    let target_diameter = if is_pressed {
649        SwitchDefaults::PRESSED_THUMB_DIAMETER
650    } else if has_thumb_content || checked {
651        SwitchDefaults::THUMB_DIAMETER
652    } else {
653        SwitchDefaults::UNCHECKED_THUMB_DIAMETER
654    };
655    let target_offset = if checked {
656        width.0
657            - SwitchDefaults::THUMB_DIAMETER.0
658            - (height.0 - SwitchDefaults::THUMB_DIAMETER.0) / 2.0
659            - if is_pressed {
660                track_outline_width.0
661            } else {
662                0.0
663            }
664    } else if is_pressed {
665        track_outline_width.0
666    } else {
667        (height.0 - target_diameter.0) / 2.0
668    };
669    let revision = controller.with(|controller| controller.snap_revision);
670    let geometry = remember(|| Geometry::new(target_offset, target_diameter.0, revision));
671    let mut current_geometry = geometry.get();
672    current_geometry.retarget(
673        target_offset,
674        target_diameter.0,
675        is_pressed,
676        revision,
677        current_frame_nanos(),
678    );
679    if geometry.with(|geometry| *geometry != current_geometry) {
680        geometry.set(current_geometry);
681    }
682    if geometry.with(Geometry::is_animating) {
683        receive_frame_nanos(move |frame| {
684            let active = geometry.with_mut(|geometry| {
685                geometry.advance(frame);
686                geometry.is_animating()
687            });
688            if active {
689                tessera_ui::FrameNanosControl::Continue
690            } else {
691                tessera_ui::FrameNanosControl::Stop
692            }
693        });
694    }
695    let thumb_diameter_dp = Dp(geometry.with(|geometry| geometry.diameter.value).max(0.0));
696    let thumb_offset = Dp(geometry.with(|geometry| geometry.offset.value));
697    let thumb_size_px = thumb_diameter_dp.to_px();
698    layout()
699        .modifier(modifier)
700        .policy(SwitchLayout {
701            track_width: width,
702            track_height: height,
703            thumb_diameter: thumb_diameter_dp,
704            thumb_offset,
705        })
706        .child(move || {
707            let track_style = if checked {
708                SurfaceStyle::Filled {
709                    color: colors.track_color,
710                }
711            } else {
712                SurfaceStyle::FilledOutlined {
713                    fill_color: colors.track_color,
714                    border_color: colors.track_outline_color,
715                    border_width: track_outline_width,
716                }
717            };
718
719            surface()
720                .modifier(Modifier::new().size(width, height))
721                .style(track_style)
722                .shape(Shape::CAPSULE)
723                .show_state_layer(false)
724                .show_ripple(false)
725                .focus_requester_optional(focus_requester)
726                .interaction_state_optional(interaction_state)
727                .presentation_state_optional(presentation_state)
728                .show_focus_indicator(true)
729                .enabled(enabled)
730                .child(|| {});
731
732            surface()
733                .modifier(Modifier::new().size(
734                    SwitchDefaults::STATE_LAYER_SIZE,
735                    SwitchDefaults::STATE_LAYER_SIZE,
736                ))
737                .shape(Shape::Ellipse)
738                .style(SurfaceStyle::Filled {
739                    color: Color::TRANSPARENT,
740                })
741                .show_state_layer(true)
742                .show_ripple(true)
743                .ripple_bounded(false)
744                .ripple_radius(Dp(SwitchDefaults::STATE_LAYER_SIZE.0 / 2.0))
745                .ripple_color(if checked {
746                    scheme.primary
747                } else {
748                    scheme.on_surface
749                })
750                .interaction_state_optional(interaction_state)
751                .presentation_state_optional(presentation_state)
752                .enabled(enabled)
753                .ripple_state_optional(ripple_state)
754                .child(|| {});
755            layout()
756                .policy(SwitchThumbLayout {
757                    size: thumb_size_px,
758                })
759                .modifier({
760                    let modifier = Modifier::new();
761                    #[cfg(test)]
762                    let modifier = modifier.semantics(crate::modifier::SemanticsArgs {
763                        test_tag: Some(THUMB_TEST_TAG.to_string()),
764                        ..Default::default()
765                    });
766                    modifier
767                })
768                .child(move || {
769                    surface()
770                        .modifier(Modifier::new().fill_max_size())
771                        .style(SurfaceStyle::Filled {
772                            color: colors.thumb_color,
773                        })
774                        .shape(Shape::Ellipse)
775                        .content_color(colors.icon_color)
776                        .child(move || {
777                            if let Some(child) = child {
778                                boxed()
779                                    .modifier(Modifier::new().fill_max_size())
780                                    .alignment(Alignment::Center)
781                                    .children(move || {
782                                        provide_context(
783                                            || IconSize(SwitchDefaults::ICON_SIZE),
784                                            move || child.render(),
785                                        );
786                                    });
787                            }
788                        });
789                });
790        });
791}
792
793#[cfg(test)]
794mod state_tests;
795
796#[cfg(test)]
797mod motion_tests;
798
799#[cfg(test)]
800mod tests {
801    use super::SwitchController;
802
803    #[test]
804    fn selection_spring_matches_reference_samples_and_settles_both_values() {
805        for checked in [false, true] {
806            let mut controller = SwitchController::new(checked);
807            controller.toggle_at(0);
808            controller.update_progress(50_000_000);
809            let expected = if checked {
810                1.0 - 0.5273674995
811            } else {
812                0.5273674995
813            };
814            assert!((controller.animation_progress() as f64 - expected).abs() < 0.000001);
815            controller.update_progress(150_000_000);
816            assert!(if checked {
817                controller.progress.value < 0.0
818            } else {
819                controller.progress.value > 1.0
820            });
821            assert!(controller.is_animating());
822            controller.update_progress(2_000_000_000);
823            assert_eq!(
824                controller.animation_progress(),
825                if checked { 0.0 } else { 1.0 }
826            );
827            assert!(!controller.is_animating());
828        }
829    }
830
831    #[test]
832    fn retarget_samples_the_incoming_position_and_preserves_velocity() {
833        let mut controller = SwitchController::new(false);
834        controller.toggle_at(0);
835        controller.update_progress(50_000_000);
836        controller.toggle_at(75_000_000);
837        assert!((controller.progress.value - 0.8269005484).abs() < 0.000001);
838        assert!((controller.progress.velocity - 9.8223966780).abs() < 0.000001);
839        controller.update_progress(76_000_000);
840        assert!(controller.progress.value > 0.8269005484);
841        controller.update_progress(2_000_000_000);
842        assert_eq!(controller.animation_progress(), 0.0);
843        assert!(!controller.is_animating());
844    }
845
846    #[test]
847    fn setting_the_current_target_finishes_an_active_transition() {
848        let mut controller = SwitchController::new(false);
849        controller.toggle_at(0);
850        controller.update_progress(50_000_000);
851        controller.set_checked(true);
852        assert_eq!(controller.animation_progress(), 1.0);
853        assert!(!controller.is_animating());
854        controller.set_checked(false);
855        assert_eq!(controller.animation_progress(), 0.0);
856        assert!(!controller.is_animating());
857    }
858}