1use 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#[derive(Clone, PartialEq)]
28pub struct GlassSwitchController {
29 checked: bool,
30 progress: f32,
31 last_toggle_frame_nanos: Option<u64>,
32}
33
34impl GlassSwitchController {
35 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 pub fn is_checked(&self) -> bool {
46 self.checked
47 }
48
49 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 pub fn toggle(&mut self) {
60 self.checked = !self.checked;
61 self.last_toggle_frame_nanos = Some(current_frame_nanos());
62 }
63
64 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 pub fn animation_progress(&self) -> f32 {
85 self.progress
86 }
87
88 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#[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}