1use std::time::Duration;
7
8use tessera_foundation::gesture::TapRecognizer;
9use tessera_ui::{
10 CallbackWith, Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier,
11 Px, PxPosition, State,
12 accesskit::{Role, Toggled},
13 current_frame_nanos,
14 layout::{MeasureScope, PlacementResult, PlacementScope, layout},
15 receive_frame_nanos, remember, tessera, use_context,
16};
17
18use crate::{
19 animation,
20 fluid_glass::fluid_glass,
21 modifier::{
22 InteractionState, ModifierExt as _, PointerEventContext, SemanticsArgs, ToggleableArgs,
23 },
24 shape_def::Shape,
25 surface::{SurfaceStyle, surface},
26 theme::{MaterialAlpha, MaterialColorScheme, MaterialTheme},
27};
28
29const TRAVEL_DURATION: Duration = Duration::from_millis(200);
30const EXPAND_BEFORE_TRAVEL_DURATION: Duration = Duration::from_millis(90);
31const SPRING_DURATION: Duration = Duration::from_millis(260);
32const TRAVEL_WITHOUT_DELAY_EXPANSION: f32 = 0.75;
33
34#[cfg(test)]
35const LENS_TEST_TAG: &str = "__fluid_switch_lens";
36
37pub struct FluidSwitchDefaults;
39
40impl FluidSwitchDefaults {
41 pub const TRACK_WIDTH: Dp = Dp(91.0);
43 pub const TRACK_HEIGHT: Dp = Dp(32.0);
45 pub const LENS_WIDTH: Dp = Dp(54.0);
47 pub const LENS_HEIGHT: Dp = Dp(32.0);
49 pub const EXPANDED_LENS_WIDTH: Dp = Dp(72.9);
51 pub const EXPANDED_LENS_HEIGHT: Dp = Dp(43.2);
53 pub const TRACK_OUTLINE_WIDTH: Dp = Dp(1.0);
55 pub const CHECKED_TINT_COLOR: Color = Color::new(0.34, 0.82, 0.42, 1.0);
57}
58
59#[derive(Clone, Copy, Debug, PartialEq)]
60struct SpringTransition {
61 start_frame_nanos: u64,
62 from: f32,
63 to: f32,
64}
65
66#[derive(Clone, Copy, Debug, PartialEq)]
67struct TravelTransition {
68 start_frame_nanos: u64,
69 from: f32,
70 to: f32,
71}
72
73struct FluidSwitchState {
74 checked: bool,
75 travel_progress: f32,
76 expansion_progress: f32,
77 travel_transition: Option<TravelTransition>,
78 expansion_transition: Option<SpringTransition>,
79}
80
81impl FluidSwitchState {
82 fn new(initial_state: bool) -> Self {
83 Self {
84 checked: initial_state,
85 travel_progress: if initial_state { 1.0 } else { 0.0 },
86 expansion_progress: 0.0,
87 travel_transition: None,
88 expansion_transition: None,
89 }
90 }
91
92 fn is_checked(&self) -> bool {
93 self.checked
94 }
95
96 fn snap_to_checked(&mut self, checked: bool) {
97 self.checked = checked;
98 self.travel_progress = if checked { 1.0 } else { 0.0 };
99 self.expansion_progress = 0.0;
100 self.travel_transition = None;
101 self.expansion_transition = None;
102 }
103
104 fn travel_progress(&self) -> f32 {
105 self.travel_progress
106 }
107
108 fn expansion_progress(&self) -> f32 {
109 self.expansion_progress
110 }
111
112 fn is_animating(&self) -> bool {
113 self.travel_transition.is_some() || self.expansion_transition.is_some()
114 }
115
116 fn press(&mut self) {
117 self.retarget_expansion(1.0, current_frame_nanos());
118 }
119
120 fn release(&mut self) {
121 self.retarget_expansion(0.0, current_frame_nanos());
122 }
123
124 fn toggle_at(&mut self, frame_nanos: u64) {
125 self.update(frame_nanos);
126 self.checked = !self.checked;
127 self.retarget_expansion(1.0, frame_nanos);
128
129 let travel_delay = if self.expansion_progress >= TRAVEL_WITHOUT_DELAY_EXPANSION {
130 0
131 } else {
132 duration_nanos(EXPAND_BEFORE_TRAVEL_DURATION)
133 };
134
135 self.travel_transition = Some(TravelTransition {
136 start_frame_nanos: frame_nanos.saturating_add(travel_delay),
137 from: self.travel_progress,
138 to: if self.checked { 1.0 } else { 0.0 },
139 });
140 }
141
142 fn animate_to_checked(&mut self, checked: bool) {
143 if self.checked != checked {
144 self.toggle_at(current_frame_nanos());
145 }
146 }
147
148 fn update(&mut self, frame_nanos: u64) {
149 self.update_expansion(frame_nanos);
150 self.update_travel(frame_nanos);
151 }
152
153 fn update_expansion(&mut self, frame_nanos: u64) {
154 let Some(transition) = self.expansion_transition else {
155 return;
156 };
157 let elapsed = frame_nanos.saturating_sub(transition.start_frame_nanos);
158 let duration = duration_nanos(SPRING_DURATION);
159 if elapsed >= duration {
160 self.expansion_progress = transition.to;
161 self.expansion_transition = None;
162 return;
163 }
164
165 let fraction = elapsed as f32 / duration as f32;
166 let spring = spring_progress(fraction);
167 self.expansion_progress = transition.from + (transition.to - transition.from) * spring;
168 }
169
170 fn update_travel(&mut self, frame_nanos: u64) {
171 let Some(transition) = self.travel_transition else {
172 return;
173 };
174
175 if frame_nanos < transition.start_frame_nanos {
176 self.travel_progress = transition.from;
177 return;
178 }
179
180 let elapsed = frame_nanos.saturating_sub(transition.start_frame_nanos);
181 let duration = duration_nanos(TRAVEL_DURATION);
182 if elapsed >= duration {
183 self.travel_progress = transition.to;
184 self.travel_transition = None;
185 self.retarget_expansion(0.0, frame_nanos);
186 return;
187 }
188
189 let fraction = elapsed as f32 / duration as f32;
190 self.travel_progress = transition.from + (transition.to - transition.from) * fraction;
191 }
192
193 fn retarget_expansion(&mut self, target: f32, frame_nanos: u64) {
194 self.update_expansion(frame_nanos);
195 let current = self.expansion_progress;
196 if (current - target).abs() <= f32::EPSILON {
197 self.expansion_progress = target;
198 self.expansion_transition = None;
199 return;
200 }
201
202 self.expansion_transition = Some(SpringTransition {
203 start_frame_nanos: frame_nanos,
204 from: current,
205 to: target,
206 });
207 }
208}
209
210impl Default for FluidSwitchState {
211 fn default() -> Self {
212 Self::new(false)
213 }
214}
215
216fn duration_nanos(duration: Duration) -> u64 {
217 duration.as_nanos().min(u64::MAX as u128) as u64
218}
219
220fn spring_progress(progress: f32) -> f32 {
221 let progress = progress.clamp(0.0, 1.0);
222 if progress >= 1.0 {
223 return 1.0;
224 }
225
226 1.0 - (-6.0 * progress).exp() * (10.0 * progress).cos()
227}
228
229fn lerp_dp(from: Dp, to: Dp, progress: f32) -> Dp {
230 Dp(from.0 + (to.0 - from.0) * progress as f64)
231}
232
233fn resolve_track_color(
234 scheme: &MaterialColorScheme,
235 accent_color: Color,
236 enabled: bool,
237 progress: f32,
238) -> Color {
239 if !enabled {
240 return scheme
241 .surface
242 .blend_over(scheme.on_surface, MaterialAlpha::DISABLED_CONTAINER * 0.5);
243 }
244
245 let off = if scheme.is_dark {
246 Color::new(0.18, 0.19, 0.18, 1.0)
247 } else {
248 Color::new(0.92, 0.92, 0.92, 1.0)
249 };
250 let on = if scheme.is_dark {
251 accent_color.lerp(&Color::new(0.0, 0.0, 0.0, 1.0), 0.18)
252 } else {
253 accent_color.lerp(&Color::new(1.0, 1.0, 1.0, 1.0), 0.32)
254 };
255 off.lerp(&on, progress)
256}
257
258fn resolve_track_outline_color(
259 scheme: &MaterialColorScheme,
260 accent_color: Color,
261 enabled: bool,
262 progress: f32,
263) -> Color {
264 if !enabled {
265 return scheme.outline.with_alpha(MaterialAlpha::DISABLED_CONTAINER);
266 }
267
268 let off = scheme.outline_variant.with_alpha(0.22);
269 let on = accent_color.with_alpha(0.7);
270 off.lerp(&on, progress)
271}
272
273fn update_state_for_frame(state: State<FluidSwitchState>, frame_nanos: u64) -> bool {
274 state.with_mut(|state| {
275 state.update(frame_nanos);
276 state.is_animating()
277 })
278}
279
280#[tessera]
330pub fn fluid_switch(
331 modifier: Option<Modifier>,
332 on_toggle: Option<CallbackWith<bool, ()>>,
333 enabled: Option<bool>,
334 checked: Option<bool>,
335 accent_color: Option<Color>,
336 #[prop(into)] accessibility_label: Option<String>,
337 #[prop(into)] accessibility_description: Option<String>,
338) {
339 let enabled = enabled.unwrap_or(true);
340 let checked_prop = checked;
341 let initial_checked = checked_prop.unwrap_or(false);
342 let accent_color = accent_color.unwrap_or(FluidSwitchDefaults::CHECKED_TINT_COLOR);
343 let state = remember(|| FluidSwitchState::new(initial_checked));
344
345 fluid_switch_content()
346 .modifier_optional(modifier)
347 .on_toggle_optional(on_toggle)
348 .enabled(enabled)
349 .checked_optional(checked_prop)
350 .accent_color(accent_color)
351 .accessibility_label_optional(accessibility_label)
352 .accessibility_description_optional(accessibility_description)
353 .state(state);
354}
355
356#[tessera]
357#[allow(clippy::too_many_arguments)]
358fn fluid_switch_content(
359 modifier: Option<Modifier>,
360 on_toggle: Option<CallbackWith<bool, ()>>,
361 enabled: Option<bool>,
362 checked: Option<bool>,
363 accent_color: Option<Color>,
364 accessibility_label: Option<String>,
365 accessibility_description: Option<String>,
366 state: Option<State<FluidSwitchState>>,
367) {
368 let enabled = enabled.unwrap_or(true);
369 let checked_prop = checked;
370 let accent_color = accent_color.unwrap_or(FluidSwitchDefaults::CHECKED_TINT_COLOR);
371 let state = state.expect("fluid_switch_content requires state to be set");
372 let scheme = use_context::<MaterialTheme>()
373 .expect("MaterialTheme must be provided")
374 .get()
375 .color_scheme;
376 let last_checked_prop = remember(|| Option::<bool>::None);
377
378 let previous_checked_prop = last_checked_prop.get();
379 if let Some(checked) = checked_prop {
380 if previous_checked_prop.is_none() {
381 state.with_mut(|state| state.snap_to_checked(checked));
382 } else if previous_checked_prop != checked_prop {
383 state.with_mut(|state| state.animate_to_checked(checked));
384 } else if state.with(|state| !state.is_animating() && state.is_checked() != checked) {
385 state.with_mut(|state| state.animate_to_checked(checked));
386 }
387 }
388 if previous_checked_prop != checked_prop {
389 last_checked_prop.set(checked_prop);
390 }
391
392 let mut modifier = modifier.unwrap_or_default();
393 let visual_checked = state.with(|state| state.is_checked());
394 let source_checked = checked_prop.unwrap_or(visual_checked);
395 let travel_progress = state.with(|state| state.travel_progress());
396 let eased_progress = animation::easing(travel_progress);
397 let expansion_progress = state.with(|state| state.expansion_progress());
398 let track_color = resolve_track_color(&scheme, accent_color, enabled, eased_progress);
399 let track_outline_color =
400 resolve_track_outline_color(&scheme, accent_color, enabled, eased_progress);
401 let has_toggle_handler = on_toggle.is_some();
402 let interaction_state = remember(InteractionState::new);
403 let tap_recognizer = remember(TapRecognizer::default);
404
405 if has_toggle_handler {
406 modifier = modifier.minimum_interactive_component_size();
407 let press_handler = move |_ctx: PointerEventContext| {
408 state.with_mut(|state| state.press());
409 };
410 let release_handler = move |_ctx: PointerEventContext| {
411 state.with_mut(|state| state.release());
412 };
413 let toggle_args = ToggleableArgs {
414 value: source_checked,
415 on_value_change: CallbackWith::new(move |next_checked| {
416 state.with_mut(|state| state.animate_to_checked(next_checked));
417 if let Some(on_toggle) = on_toggle.as_ref() {
418 on_toggle.call(next_checked);
419 }
420 }),
421 enabled,
422 role: Some(Role::Switch),
423 label: accessibility_label.clone(),
424 description: accessibility_description.clone(),
425 interaction_state: Some(interaction_state),
426 on_press: Some(press_handler.into()),
427 on_release: Some(release_handler.into()),
428 tap_recognizer: Some(tap_recognizer),
429 ..Default::default()
430 };
431 modifier = modifier.toggleable_with(toggle_args);
432 } else {
433 modifier = modifier.semantics(SemanticsArgs {
434 role: Some(Role::Switch),
435 label: accessibility_label.clone(),
436 description: accessibility_description.clone(),
437 toggled: Some(if source_checked {
438 Toggled::True
439 } else {
440 Toggled::False
441 }),
442 disabled: !enabled,
443 ..Default::default()
444 });
445 }
446
447 let should_continue = update_state_for_frame(state, current_frame_nanos());
448 if should_continue {
449 receive_frame_nanos(move |frame_nanos| {
450 if update_state_for_frame(state, frame_nanos) {
451 tessera_ui::FrameNanosControl::Continue
452 } else {
453 tessera_ui::FrameNanosControl::Stop
454 }
455 });
456 }
457
458 let lens_width = lerp_dp(
459 FluidSwitchDefaults::LENS_WIDTH,
460 FluidSwitchDefaults::EXPANDED_LENS_WIDTH,
461 expansion_progress,
462 );
463 let lens_height = lerp_dp(
464 FluidSwitchDefaults::LENS_HEIGHT,
465 FluidSwitchDefaults::EXPANDED_LENS_HEIGHT,
466 expansion_progress,
467 );
468
469 layout()
470 .modifier(modifier)
471 .policy(FluidSwitchLayout {
472 track_width: FluidSwitchDefaults::TRACK_WIDTH.to_px(),
473 track_height: FluidSwitchDefaults::TRACK_HEIGHT.to_px(),
474 lens_width: lens_width.to_px(),
475 lens_height: lens_height.to_px(),
476 progress: travel_progress,
477 })
478 .child(move || {
479 surface()
480 .modifier(Modifier::new().size(
481 FluidSwitchDefaults::TRACK_WIDTH,
482 FluidSwitchDefaults::TRACK_HEIGHT,
483 ))
484 .style(SurfaceStyle::FilledOutlined {
485 fill_color: track_color,
486 border_color: track_outline_color,
487 border_width: FluidSwitchDefaults::TRACK_OUTLINE_WIDTH,
488 })
489 .shape(Shape::CAPSULE)
490 .show_state_layer(false)
491 .show_ripple(false)
492 .child(|| {});
493
494 let lens_modifier = Modifier::new().size(lens_width, lens_height);
495 #[cfg(test)]
496 let lens_modifier = lens_modifier.semantics(SemanticsArgs {
497 test_tag: Some(LENS_TEST_TAG.to_string()),
498 ..Default::default()
499 });
500
501 fluid_glass()
502 .modifier(lens_modifier)
503 .shape(Shape::CAPSULE)
504 .block_input(false);
505 });
506}
507
508#[derive(Clone, PartialEq)]
509struct FluidSwitchLayout {
510 track_width: Px,
511 track_height: Px,
512 lens_width: Px,
513 lens_height: Px,
514 progress: f32,
515}
516
517impl tessera_ui::LayoutModifierNode for FluidSwitchLayout {
518 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
519 let mut result = LayoutResult::default();
520 let children = input.children();
521 if children.len() < 2 {
522 return Ok(result.with_size(ComputedData {
523 width: self.track_width,
524 height: self.track_height.max(self.lens_height),
525 }));
526 }
527
528 let track = children[0];
529 let lens = children[1];
530 let track_constraint = Constraint::exact(self.track_width, self.track_height);
531 let lens_constraint = Constraint::exact(self.lens_width, self.lens_height);
532
533 let track_size = track.measure(input, &track_constraint)?;
534 let lens_size = lens.measure(input, &lens_constraint)?;
535 let width = track_size.width.max(lens_size.width);
536 let height = track_size.height.max(lens_size.height);
537 let track_x = (width - track_size.width) / 2;
538 let track_y = (height - track_size.height) / 2;
539 let lens_y = (height - lens_size.height) / 2;
540 let lens_x = compute_lens_x(track_x, track_size.width, lens_size.width, self.progress);
541
542 result.place_child(track, PxPosition::new(track_x, track_y));
543 result.place_child(lens, PxPosition::new(lens_x, lens_y));
544
545 Ok(result.with_size(ComputedData { width, height }))
546 }
547
548 fn measure_eq(&self, other: &Self) -> bool {
549 self.track_width == other.track_width
550 && self.track_height == other.track_height
551 && self.lens_width == other.lens_width
552 && self.lens_height == other.lens_height
553 }
554
555 fn placement_eq(&self, other: &Self) -> bool {
556 self.measure_eq(other) && self.progress == other.progress
557 }
558
559 fn place_children(
560 &self,
561 input: &PlacementScope<'_>,
562 _inner: &PlacementResult,
563 ) -> Option<PlacementResult> {
564 let mut result = LayoutResult::default();
565 let children = input.children();
566 if children.len() < 2 {
567 return Some(result.into_placement_result());
568 }
569
570 let track = children[0];
571 let lens = children[1];
572 let track_size = track.size();
573 let lens_size = lens.size();
574 let width = input.size().width;
575 let height = input.size().height;
576 let track_x = (width - track_size.width) / 2;
577 let track_y = (height - track_size.height) / 2;
578 let lens_y = (height - lens_size.height) / 2;
579 let lens_x = compute_lens_x(track_x, track_size.width, lens_size.width, self.progress);
580
581 result.place_child(track, PxPosition::new(track_x, track_y));
582 result.place_child(lens, PxPosition::new(lens_x, lens_y));
583 Some(result.into_placement_result())
584 }
585}
586
587fn compute_lens_x(track_x: Px, track_width: Px, lens_width: Px, progress: f32) -> Px {
588 let eased_progress = animation::easing(progress);
589 let start = track_x.0;
590 let end = track_x.0 + track_width.0 - lens_width.0;
591 Px(start + ((end - start) as f32 * eased_progress).round() as i32)
592}
593
594#[cfg(test)]
595mod tests {
596 use tessera_ui::tessera;
597
598 use crate::{
599 fluid_switch::{
600 EXPAND_BEFORE_TRAVEL_DURATION, FluidSwitchDefaults, FluidSwitchState, LENS_TEST_TAG,
601 SPRING_DURATION, TRAVEL_DURATION, fluid_switch_content,
602 },
603 theme::{MaterialTheme, material_theme},
604 };
605
606 #[tessera]
607 fn animated_lens_probe() {
608 let state = tessera_ui::remember(|| FluidSwitchState::new(false));
609 let started = tessera_ui::remember(|| false);
610
611 if !started.get() {
612 state.with_mut(|state| state.toggle_at(0));
613 started.set(true);
614 }
615
616 state.with_mut(|state| {
617 state.update(tessera_ui::current_frame_nanos());
618 });
619
620 material_theme()
621 .theme(MaterialTheme::default)
622 .child(move || {
623 fluid_switch_content()
624 .on_toggle(|_| {})
625 .enabled(true)
626 .accent_color(FluidSwitchDefaults::CHECKED_TINT_COLOR)
627 .state(state);
628 });
629 }
630
631 #[test]
632 fn fluid_switch_state_delays_travel_until_lens_expands() {
633 let mut state = FluidSwitchState::new(false);
634
635 state.toggle_at(0);
636
637 assert!(state.is_checked());
638 assert!(state.is_animating());
639 assert_eq!(state.travel_progress(), 0.0);
640
641 let delay_nanos = EXPAND_BEFORE_TRAVEL_DURATION.as_nanos() as u64;
642 state.update(delay_nanos - 1);
643 assert_eq!(state.travel_progress(), 0.0);
644 assert!(state.expansion_progress() > 0.0);
645
646 state.update(delay_nanos + (TRAVEL_DURATION.as_nanos() / 2) as u64);
647 let mid = state.travel_progress();
648 assert!(mid > 0.0 && mid < 1.0, "mid travel progress was {mid}");
649
650 state.update(delay_nanos + TRAVEL_DURATION.as_nanos() as u64);
651 assert_eq!(state.travel_progress(), 1.0);
652 assert!(state.expansion_progress() > 0.0);
653
654 state.update(
655 delay_nanos + TRAVEL_DURATION.as_nanos() as u64 + SPRING_DURATION.as_nanos() as u64,
656 );
657 assert_eq!(state.expansion_progress(), 0.0);
658 assert!(!state.is_animating());
659 }
660
661 #[test]
662 fn fluid_switch_state_ignores_redundant_animate_target() {
663 let runtime = tessera_ui::RuntimeSession::new();
664 let _scope = runtime.enter();
665 let mut state = FluidSwitchState::new(false);
666
667 state.animate_to_checked(false);
668
669 assert!(!state.is_checked());
670 assert!(!state.is_animating());
671
672 state.animate_to_checked(true);
673
674 assert!(state.is_checked());
675 assert!(state.is_animating());
676 }
677
678 #[test]
679 fn fluid_switch_lens_expands_before_travel() {
680 let delay_nanos = EXPAND_BEFORE_TRAVEL_DURATION.as_nanos() as u64;
681 tessera_ui::assert_layout! {
682 viewport: (200, 100),
683 content: {
684 animated_lens_probe();
685 },
686 expect: {
687 0 => {
688 node(LENS_TEST_TAG).size(54, 32);
689 },
690 delay_nanos => {
691 node(LENS_TEST_TAG).size(75, 44);
692 }
693 }
694 }
695 }
696}