Skip to main content

tessera_components/
fluid_switch.rs

1//! Fluid glass switch - toggle state with a surface track and glass lens.
2//!
3//! ## Usage
4//!
5//! Toggle boolean state over visible surface-backed content.
6use 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
37/// Defaults for [`fluid_switch`].
38pub struct FluidSwitchDefaults;
39
40impl FluidSwitchDefaults {
41    /// Default track width.
42    pub const TRACK_WIDTH: Dp = Dp(91.0);
43    /// Default track height.
44    pub const TRACK_HEIGHT: Dp = Dp(32.0);
45    /// Resting lens width.
46    pub const LENS_WIDTH: Dp = Dp(54.0);
47    /// Resting lens height.
48    pub const LENS_HEIGHT: Dp = Dp(32.0);
49    /// Pressed or traveling lens width.
50    pub const EXPANDED_LENS_WIDTH: Dp = Dp(72.9);
51    /// Pressed or traveling lens height.
52    pub const EXPANDED_LENS_HEIGHT: Dp = Dp(43.2);
53    /// Track outline width.
54    pub const TRACK_OUTLINE_WIDTH: Dp = Dp(1.0);
55    /// Default checked substrate tint.
56    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/// # fluid_switch
281///
282/// Render a fluid glass switch for prominent boolean toggles over visible
283/// surface-backed content.
284///
285/// ## Usage
286///
287/// Use for settings or mode toggles where the moving glass lens is the primary
288/// interaction cue.
289///
290/// ## Parameters
291///
292/// - `modifier` - optional modifier chain applied to the switch subtree.
293/// - `on_toggle` - optional callback invoked when the switch toggles.
294/// - `enabled` - optional enabled state; defaults to `true`.
295/// - `checked` - optional external checked state. When omitted, the switch
296///   manages its own state internally.
297/// - `accent_color` - optional checked-state tint blended into the surface
298///   substrate; defaults to a pale green.
299/// - `accessibility_label` - optional accessibility label.
300/// - `accessibility_description` - optional accessibility description.
301///
302/// ## Examples
303///
304/// ```
305/// use tessera_components::{
306///     fluid_switch::fluid_switch,
307///     surface::surface,
308///     theme::{MaterialTheme, material_theme},
309/// };
310///
311/// # use tessera_ui::tessera;
312/// # #[tessera]
313/// # fn component() {
314/// let checked = tessera_ui::remember(|| false);
315/// assert!(!checked.get());
316///
317/// material_theme()
318///     .theme(|| MaterialTheme::default())
319///     .child(move || {
320///         surface().child(move || {
321///             fluid_switch()
322///                 .checked(checked.get())
323///                 .on_toggle(move |next_checked| checked.set(next_checked));
324///         });
325///     });
326/// # }
327/// # tessera_ui::testing::layout_test(component).run();
328/// ```
329#[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}