1use 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
39pub 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 pub const WIDTH: Dp = Dp(52.0);
60 pub const HEIGHT: Dp = Dp(32.0);
62 pub const STATE_LAYER_SIZE: Dp = Dp(40.0);
65 pub const THUMB_DIAMETER: Dp = Dp(24.0);
67 pub const UNCHECKED_THUMB_DIAMETER: Dp = Dp(16.0);
69 pub const PRESSED_THUMB_DIAMETER: Dp = Dp(28.0);
71 pub const TRACK_OUTLINE_WIDTH: Dp = Dp(2.0);
73 pub const ICON_SIZE: Dp = Dp(16.0);
75
76 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
291pub struct SwitchController {
293 checked: bool,
294 progress: Scalar,
295 snap_revision: u64,
296}
297
298impl SwitchController {
299 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 pub fn is_checked(&self) -> bool {
310 self.checked
311 }
312
313 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 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 pub fn animation_progress(&self) -> f32 {
336 self.progress.value.clamp(0.0, 1.0) as f32
337 }
338
339 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#[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}