Skip to main content

tessera_components/
glass_switch.rs

1//! A switch (toggle) component with a glassmorphic visual style.
2//!
3//! ## Usage
4//!
5//! Use in settings, forms, or toolbars to control a boolean state.
6use std::time::Duration;
7
8use tessera_ui::{
9    AxisConstraint, CallbackWith, Color, ComputedData, Constraint, Dp, LayoutResult,
10    MeasurementError, Modifier, Px, PxPosition, State,
11    accesskit::Role,
12    current_frame_nanos,
13    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
14    receive_frame_nanos, remember, tessera,
15};
16
17use crate::{
18    animation,
19    fluid_glass::{GlassBorder, fluid_glass},
20    modifier::{InteractionState, ModifierExt as _, ToggleableArgs},
21    shape_def::Shape,
22};
23
24const ANIMATION_DURATION: Duration = Duration::from_millis(150);
25
26/// Controller for the `glass_switch` component.
27#[derive(Clone, PartialEq)]
28pub struct GlassSwitchController {
29    checked: bool,
30    progress: f32,
31    last_toggle_frame_nanos: Option<u64>,
32}
33
34impl GlassSwitchController {
35    /// Creates a new controller with the given initial value.
36    pub fn new(initial_state: bool) -> Self {
37        Self {
38            checked: initial_state,
39            progress: if initial_state { 1.0 } else { 0.0 },
40            last_toggle_frame_nanos: None,
41        }
42    }
43
44    /// Returns whether the switch is currently checked.
45    pub fn is_checked(&self) -> bool {
46        self.checked
47    }
48
49    /// Sets the checked state directly, resetting animation progress.
50    pub fn set_checked(&mut self, checked: bool) {
51        if self.checked != checked {
52            self.checked = checked;
53            self.progress = if checked { 1.0 } else { 0.0 };
54            self.last_toggle_frame_nanos = None;
55        }
56    }
57
58    /// Toggles the switch and starts the animation timeline.
59    pub fn toggle(&mut self) {
60        self.checked = !self.checked;
61        self.last_toggle_frame_nanos = Some(current_frame_nanos());
62    }
63
64    /// Advances the animation timeline based on elapsed time.
65    pub fn update_progress(&mut self, frame_nanos: u64) {
66        if let Some(start_frame_nanos) = self.last_toggle_frame_nanos {
67            let elapsed_nanos = frame_nanos.saturating_sub(start_frame_nanos);
68            let animation_nanos = ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
69            let fraction = if animation_nanos == 0 {
70                1.0
71            } else {
72                (elapsed_nanos as f32 / animation_nanos as f32).min(1.0)
73            };
74            let target = if self.checked { 1.0 } else { 0.0 };
75            self.progress = target * fraction + (1.0 - fraction) * (1.0 - target);
76            if fraction >= 1.0 {
77                self.last_toggle_frame_nanos = None;
78                self.progress = target;
79            }
80        }
81    }
82
83    /// Returns the current animation progress (0.0..1.0).
84    pub fn animation_progress(&self) -> f32 {
85        self.progress
86    }
87
88    /// Returns whether the toggle animation is currently running.
89    pub fn is_animating(&self) -> bool {
90        self.last_toggle_frame_nanos.is_some()
91    }
92}
93
94impl Default for GlassSwitchController {
95    fn default() -> Self {
96        Self::new(false)
97    }
98}
99
100fn interpolate_color(off: Color, on: Color, progress: f32) -> Color {
101    Color {
102        r: off.r + (on.r - off.r) * progress,
103        g: off.g + (on.g - off.g) * progress,
104        b: off.b + (on.b - off.b) * progress,
105        a: off.a + (on.a - off.a) * progress,
106    }
107}
108
109/// # glass_switch
110///
111/// An interactive switch with glass effect.
112///
113/// ## Usage
114///
115/// Use when you need a toggle switch with a glassmorphic style.
116///
117/// ## Parameters
118///
119/// - `modifier` — optional modifier chain applied to the switch subtree.
120/// - `on_toggle` — optional callback invoked when the switch toggles.
121/// - `checked` — initial checked state.
122/// - `width` — optional track width override.
123/// - `height` — optional track height override.
124/// - `track_on_color` — optional track tint when checked.
125/// - `track_off_color` — optional track tint when unchecked.
126/// - `thumb_on_alpha` — optional thumb alpha when checked.
127/// - `thumb_off_alpha` — optional thumb alpha when unchecked.
128/// - `thumb_border` — optional thumb border override.
129/// - `track_border` — optional track border override.
130/// - `thumb_padding` — optional thumb padding.
131/// - `accessibility_label` — optional accessibility label.
132/// - `accessibility_description` — optional accessibility description.
133/// - `controller` — optional external controller.
134///
135/// ## Examples
136///
137/// ```
138/// use tessera_components::glass_switch::{GlassSwitchController, glass_switch};
139/// use tessera_components::theme::{MaterialTheme, material_theme};
140/// use tessera_ui::{LayoutResult, remember, tessera};
141///
142/// #[tessera]
143/// fn demo() {
144///     let controller = remember(|| GlassSwitchController::new(false));
145///     assert!(!controller.with(|c| c.is_checked()));
146///
147///     material_theme()
148///         .theme(|| MaterialTheme::default())
149///         .child(move || {
150///             glass_switch().checked(controller.with(|c| c.is_checked()));
151///         });
152///
153///     controller.with_mut(|c| c.toggle());
154///     assert!(controller.with(|c| c.is_checked()));
155/// }
156///
157/// # tessera_ui::testing::layout_test(demo).run();
158/// ```
159#[tessera]
160pub fn glass_switch(
161    modifier: Option<Modifier>,
162    on_toggle: Option<CallbackWith<bool, ()>>,
163    checked: Option<bool>,
164    width: Option<Dp>,
165    height: Option<Dp>,
166    track_on_color: Option<Color>,
167    track_off_color: Option<Color>,
168    thumb_on_alpha: Option<f32>,
169    thumb_off_alpha: Option<f32>,
170    thumb_border: Option<GlassBorder>,
171    track_border: Option<GlassBorder>,
172    thumb_padding: Option<Dp>,
173    #[prop(into)] accessibility_label: Option<String>,
174    #[prop(into)] accessibility_description: Option<String>,
175    #[prop(skip_setter)] controller: Option<State<GlassSwitchController>>,
176) {
177    let checked = checked.unwrap_or(false);
178    let width = width.unwrap_or(Dp(52.0));
179    let height = height.unwrap_or(Dp(32.0));
180    let track_on_color = track_on_color.unwrap_or(Color::new(0.2, 0.7, 1.0, 0.5));
181    let track_off_color = track_off_color.unwrap_or(Color::new(0.8, 0.8, 0.8, 0.5));
182    let thumb_on_alpha = thumb_on_alpha.unwrap_or(0.5);
183    let thumb_off_alpha = thumb_off_alpha.unwrap_or(1.0);
184    let thumb_padding = thumb_padding.unwrap_or(Dp(3.0));
185    let controller = match controller {
186        Some(value) => value,
187        None => remember(|| GlassSwitchController::new(checked)),
188    };
189    if controller.with(|c| c.is_checked()) != checked {
190        controller.with_mut(|c| c.set_checked(checked));
191    }
192    let mut modifier = modifier.unwrap_or_default();
193
194    let enabled = on_toggle.is_some();
195    let interaction_state = if enabled {
196        Some(remember(InteractionState::new))
197    } else {
198        None
199    };
200    let checked = controller.with(|c| c.is_checked());
201    if enabled {
202        modifier = modifier.minimum_interactive_component_size();
203        let toggle_args = ToggleableArgs {
204            value: checked,
205            on_value_change: CallbackWith::new(move |_| {
206                controller.with_mut(|c| c.toggle());
207                let checked = controller.with(|c| c.is_checked());
208                if let Some(on_toggle) = on_toggle.as_ref() {
209                    on_toggle.call(checked);
210                }
211            }),
212            enabled: true,
213            role: Some(Role::Switch),
214            label: accessibility_label.clone(),
215            description: accessibility_description.clone(),
216            interaction_state,
217            ..Default::default()
218        };
219        modifier = modifier.toggleable_with(toggle_args);
220    }
221
222    let width_px = width.to_px();
223    let height_px = height.to_px();
224    let thumb_dp = Dp(height.0 - (thumb_padding.0 * 2.0));
225    let thumb_px = thumb_dp.to_px();
226
227    if controller.with(|c| c.is_animating()) {
228        receive_frame_nanos(move |frame_nanos| {
229            let is_animating = controller.with_mut(|controller| {
230                controller.update_progress(frame_nanos);
231                controller.is_animating()
232            });
233            if is_animating {
234                tessera_ui::FrameNanosControl::Continue
235            } else {
236                tessera_ui::FrameNanosControl::Stop
237            }
238        });
239    }
240    let progress = controller.with(|c| c.animation_progress());
241    let track_color = interpolate_color(track_off_color, track_on_color, progress);
242
243    layout()
244        .modifier(modifier)
245        .policy(GlassSwitchLayout {
246            width: width_px,
247            height: height_px,
248            thumb_padding: thumb_padding.to_px(),
249            progress,
250        })
251        .child(move || {
252            fluid_glass()
253                .modifier(Modifier::new().constrain(
254                    Some(AxisConstraint::exact(width_px)),
255                    Some(AxisConstraint::exact(height_px)),
256                ))
257                .tint_color(track_color)
258                .shape(Shape::CAPSULE)
259                .blur_radius(Dp(8.0))
260                .border_optional(track_border)
261                .child(|| {});
262
263            let thumb_alpha = thumb_off_alpha + (thumb_on_alpha - thumb_off_alpha) * progress;
264            let thumb_color = Color::new(1.0, 1.0, 1.0, thumb_alpha);
265            fluid_glass()
266                .modifier(Modifier::new().constrain(
267                    Some(AxisConstraint::exact(thumb_px)),
268                    Some(AxisConstraint::exact(thumb_px)),
269                ))
270                .tint_color(thumb_color)
271                .shape(Shape::Ellipse)
272                .border_optional(thumb_border)
273                .child(|| {});
274        });
275}
276
277#[derive(Clone, PartialEq)]
278struct GlassSwitchLayout {
279    width: Px,
280    height: Px,
281    thumb_padding: Px,
282    progress: f32,
283}
284
285impl tessera_ui::LayoutModifierNode for GlassSwitchLayout {
286    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
287        let mut result = LayoutResult::default();
288        let children = input.children();
289        let track = children[0];
290        let thumb = children[1];
291
292        let track_constraint = Constraint::exact(self.width, self.height);
293        let thumb_constraint = Constraint::NONE;
294
295        let _ = track.measure(input, &track_constraint)?;
296        let thumb_size = thumb.measure(input, &thumb_constraint)?;
297
298        let eased_progress = animation::easing(self.progress);
299
300        result.place_child(track, PxPosition::new(Px(0), Px(0)));
301
302        let start_x = self.thumb_padding;
303        let end_x = self.width - thumb_size.width - self.thumb_padding;
304        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased_progress;
305        let thumb_y = (self.height - thumb_size.height) / 2;
306
307        result.place_child(thumb, PxPosition::new(Px(thumb_x as i32), thumb_y));
308
309        Ok(result.with_size(ComputedData {
310            width: self.width,
311            height: self.height,
312        }))
313    }
314
315    fn measure_eq(&self, other: &Self) -> bool {
316        self.width == other.width
317            && self.height == other.height
318            && self.thumb_padding == other.thumb_padding
319    }
320
321    fn placement_eq(&self, other: &Self) -> bool {
322        self.width == other.width
323            && self.height == other.height
324            && self.thumb_padding == other.thumb_padding
325            && self.progress == other.progress
326    }
327
328    fn place_children(
329        &self,
330        input: &PlacementScope<'_>,
331        _inner: &PlacementResult,
332    ) -> Option<PlacementResult> {
333        let mut result = LayoutResult::default();
334        let children = input.children();
335        if children.len() < 2 {
336            return Some(result.into_placement_result());
337        }
338        let track = children[0];
339        let thumb = children[1];
340        let thumb_size = thumb.size();
341
342        let eased_progress = animation::easing(self.progress);
343        result.place_child(track, PxPosition::new(Px(0), Px(0)));
344
345        let start_x = self.thumb_padding;
346        let end_x = self.width - thumb_size.width - self.thumb_padding;
347        let thumb_x = start_x.0 as f32 + (end_x.0 - start_x.0) as f32 * eased_progress;
348        let thumb_y = (self.height - thumb_size.height) / 2;
349
350        result.place_child(thumb, PxPosition::new(Px(thumb_x as i32), thumb_y));
351        Some(result.into_placement_result())
352    }
353}