1use cranpose_animation::{AnimationSpec, AnimationType, Easing, animateColorAsState, spring};
2use cranpose_core::{mutableStateOf, remember};
3use cranpose_macros::composable;
4use cranpose_services::{HapticFeedback, default_haptics};
5use cranpose_ui::{
6 Modifier, PointerEventKind, PointerInputScope, Size,
7 widgets::{Box, BoxSpec},
8};
9use cranpose_ui_graphics::{Brush, CornerRadii, GraphicsLayer};
10
11use crate::{
12 material::{GlassDynamics, GlassMorph, LiquidModifierExt},
13 motion::LiquidMotion,
14 theme::liquid_colors,
15};
16
17pub(crate) const TRACK_WIDTH: f32 = 63.0;
18pub(crate) const TRACK_HEIGHT: f32 = 28.0;
19const THUMB_WIDTH: f32 = 37.0;
20const THUMB_HEIGHT: f32 = 24.0;
21const THUMB_MARGIN: f32 = 2.0;
22const LENS_WIDTH: f32 = 58.0;
23const LENS_HEIGHT: f32 = 115.0 / 3.0;
24const LENS_VERTICAL_OFFSET: f32 = 0.0;
25const LENS_PAD: f32 = 18.0;
26const TAP_SLOP: f32 = 4.0;
27
28fn toggle_track_motion() -> AnimationType {
29 AnimationType::Tween(AnimationSpec::tween(260, Easing::EaseOut))
30}
31
32fn lens_translation_x(thumb_x: f32, node_width: f32) -> f32 {
33 thumb_x + (THUMB_WIDTH - node_width) * 0.5
34}
35
36fn lens_ride_x(drag_progress: Option<f32>, thumb_x: f32) -> f32 {
37 drag_progress.map_or(thumb_x, |progress| {
38 let min_x = THUMB_MARGIN;
39 let max_x = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
40 min_x + (max_x - min_x) * progress.clamp(0.0, 1.0)
41 })
42}
43
44#[composable]
47pub fn LiquidToggle(modifier: Modifier, checked: bool, on_change: impl Fn(bool) + 'static) {
48 let colors = liquid_colors();
49
50 let drag_progress = remember(|| mutableStateOf(Option::<f32>::None)).with(|s| *s);
51 let off_track = colors.toggle_off;
52 let base_track = if checked { colors.toggle_on } else { off_track };
53 let animated_track = animateColorAsState(base_track, toggle_track_motion(), "toggle-track");
54
55 let min_x = THUMB_MARGIN;
56 let max_x = TRACK_WIDTH - THUMB_MARGIN - THUMB_WIDTH;
57
58 let target_x = if checked { max_x } else { min_x };
59 let lens_axis = crate::motion::remember_liquid_follow_axis(target_x, spring(1.0, 300.0));
60 let shape = super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Thumb);
61 lens_axis.settle_to(target_x, LiquidMotion::snappy());
62 let contact = super::control_motion::remember_control_contact(
63 super::control_motion::ControlContactKind::Thumb,
64 );
65 let (lens_progress, material_progress) = contact.states();
66
67 let on_change = std::rc::Rc::new(on_change);
68 let track = Modifier::empty()
69 .size(Size::new(TRACK_WIDTH, TRACK_HEIGHT))
70 .stable_semantics(switch_semantics(checked))
71 .focusable()
72 .pointer_input(checked, {
73 let on_change = std::rc::Rc::clone(&on_change);
74 let lens_axis = std::rc::Rc::clone(&lens_axis);
75 let contact = std::rc::Rc::clone(&contact);
76 move |scope: PointerInputScope| {
77 let on_change = std::rc::Rc::clone(&on_change);
78 let lens_axis = std::rc::Rc::clone(&lens_axis);
79 let contact = std::rc::Rc::clone(&contact);
80 async move {
81 scope
82 .await_pointer_event_scope(|await_scope| async move {
83 let mut down_x = 0.0f32;
84 let mut grab_offset = 0.0f32;
85 let mut dragging = false;
86 loop {
87 let event = await_scope.await_pointer_event().await;
88 match event.kind {
89 PointerEventKind::Down => {
90 dragging = true;
91 down_x = event.position.x;
92 grab_offset = event.position.x
93 - (lens_axis.value() + THUMB_WIDTH * 0.5);
94 lens_axis.begin(lens_axis.value(), event.time_ms);
95 contact.pressed(true, event.animation_time_nanos);
96 default_haptics().perform(HapticFeedback::Selection);
97 event.consume();
98 }
99 PointerEventKind::Move if dragging => {
100 let progress = ((event.position.x
101 - grab_offset
102 - THUMB_MARGIN
103 - THUMB_WIDTH * 0.5)
104 / (TRACK_WIDTH - 2.0 * THUMB_MARGIN - THUMB_WIDTH))
105 .clamp(0.0, 1.0);
106 drag_progress.set(Some(progress));
107 lens_axis.move_to(
108 lens_ride_x(Some(progress), lens_axis.value()),
109 event.time_ms,
110 );
111 event.consume();
112 }
113 PointerEventKind::Up if dragging => {
114 dragging = false;
115 contact.pressed(false, event.animation_time_nanos);
116 let travelled =
117 (event.position.x - down_x).abs() > TAP_SLOP;
118 let next = if travelled {
119 drag_progress
120 .get()
121 .map(|p| p >= 0.5)
122 .unwrap_or(!checked)
123 } else {
124 !checked
125 };
126 lens_axis.release_to(
127 if next { max_x } else { min_x },
128 event.time_ms,
129 LiquidMotion::glide(),
130 );
131 drag_progress.set(None);
132 if next != checked {
133 default_haptics().perform(HapticFeedback::ImpactLight);
134 on_change(next);
135 }
136 event.consume();
137 }
138 PointerEventKind::Cancel if dragging => {
139 dragging = false;
140 contact.pressed(false, event.animation_time_nanos);
141 lens_axis.release_to(
142 if checked { max_x } else { min_x },
143 event.time_ms,
144 LiquidMotion::snappy(),
145 );
146 drag_progress.set(None);
147 event.consume();
148 }
149 _ => {}
150 }
151 }
152 })
153 .await;
154 }
155 }
156 })
157 .draw_behind(move |scope| {
158 scope.draw_round_rect(
159 Brush::solid(animated_track.get()),
160 CornerRadii::uniform(TRACK_HEIGHT * 0.5),
161 );
162 });
163
164 Box(modifier.then(track), BoxSpec::default(), move || {
165 let Size {
166 width: node_w,
167 height: node_h,
168 } = super::control_lens::node_size(
169 Size::new(THUMB_WIDTH, THUMB_HEIGHT),
170 Size::new(LENS_WIDTH, LENS_HEIGHT),
171 LENS_PAD,
172 );
173 let shape = std::rc::Rc::clone(&shape);
174
175 let lens_for_layer = lens_progress;
176 let physics_axis = std::rc::Rc::clone(&lens_axis);
177 let layer_axis = std::rc::Rc::clone(&lens_axis);
178 let lens = Modifier::empty()
179 .required_size(Size::new(node_w, node_h))
180 .offset(0.0, (TRACK_HEIGHT - node_h) * 0.5 + LENS_VERTICAL_OFFSET)
181 .graphics_layer(move || GraphicsLayer {
182 translation_x: lens_translation_x(layer_axis.value(), node_w),
183 ..Default::default()
184 })
185 .glass_effect_with(
186 super::control_material::material(super::control_motion::ControlContactKind::Thumb),
187 move || {
188 let grow = lens_for_layer.get().clamp(-0.1, 1.2);
189 let base_w = THUMB_WIDTH + (LENS_WIDTH - THUMB_WIDTH) * grow;
190 let base_h = THUMB_HEIGHT + (LENS_HEIGHT - THUMB_HEIGHT) * grow;
191 let projection = shape.projection(physics_axis.value());
192 let material = material_progress.get().max(0.0);
193 GlassDynamics {
194 optical_projection: Some(projection),
195 resting_tint: Some(cranpose_ui_graphics::Color::WHITE),
196 morph: Some(GlassMorph {
197 node_size: (node_w, node_h),
198 primary: (node_w * 0.5, node_h * 0.5, base_w, base_h, -1.0),
199 shapes: Vec::new(),
200 glue: 0.0,
201 wobble_amplitude: 0.0,
202 wobble_phase: 0.0,
203 bulge_amplitude: 0.0,
204 bulge_direction: 0.0,
205 ellipse_blend: 0.0,
206 capsule_smoothing_dp: 0.0,
207 deformation: None,
208 zoom_anchor: (0.0, 0.0),
209 }),
210 ..super::control_material::dynamics(
211 super::control_motion::ControlContactKind::Thumb,
212 grow,
213 material,
214 )
215 }
216 },
217 );
218 Box(lens, BoxSpec::default(), || {});
219 });
220}
221
222fn switch_semantics(checked: bool) -> impl Fn(&mut cranpose_ui::SemanticsConfiguration) {
223 move |config| {
224 config.role = Some(cranpose_ui::SemanticsWidgetRole::Switch);
225 config.is_clickable = true;
226 config.toggled = Some(checked);
227 }
228}
229
230#[cfg(test)]
231#[path = "tests/toggle_tests.rs"]
232mod tests;