1use std::{
5 cell::{Cell, RefCell},
6 rc::Rc,
7};
8
9use cranpose_animation::{Animatable, AnimationType, spring};
10use cranpose_core::{RuntimeHandle, State, with_current_composer};
11use cranpose_foundation::VelocityTracker1D;
12use cranpose_macros::composable;
13use cranpose_ui::{Modifier, MutableInteractionSource};
14use cranpose_ui_graphics::GraphicsLayer;
15
16const VISUAL_HANDOFF_TOLERANCE_IN_ITEMS: f32 = 0.12;
17
18pub(crate) fn liquid_visual_index(
19 selected: usize,
20 lens_position: f32,
21 item_width: f32,
22 count: usize,
23 lens_owns_selection: bool,
24) -> usize {
25 if count == 0 {
26 return 0;
27 }
28 let selected = selected.min(count - 1);
29 if !lens_owns_selection || !lens_position.is_finite() || item_width <= f32::EPSILON {
30 return selected;
31 }
32 ((lens_position + item_width * 0.5) / item_width)
33 .floor()
34 .clamp(0.0, count.saturating_sub(1) as f32) as usize
35}
36
37pub(crate) fn liquid_axis_owns_visual_selection(
38 direct: bool,
39 lens_position: f32,
40 state_position: f32,
41 item_width: f32,
42) -> bool {
43 direct
44 || (lens_position - state_position).abs()
45 > item_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_ITEMS
46}
47
48pub struct LiquidMotion;
51
52impl LiquidMotion {
53 pub fn snappy() -> AnimationType {
55 spring(0.85, 900.0)
56 }
57
58 pub fn bouncy() -> AnimationType {
60 spring(0.55, 500.0)
61 }
62
63 pub fn smooth() -> AnimationType {
65 spring(1.0, 400.0)
66 }
67
68 pub fn blob_leading() -> AnimationType {
70 spring(0.8, 900.0)
71 }
72
73 pub fn blob_trailing() -> AnimationType {
76 spring(0.9, 380.0)
77 }
78
79 pub fn glide() -> AnimationType {
84 spring(1.0, 500.0)
85 }
86}
87
88pub(crate) struct LiquidDragAxis {
89 animation: RefCell<Animatable<f32>>,
90 follow: Option<AnimationType>,
91 pointer: Cell<Option<f32>>,
92 velocity: RefCell<VelocityTracker1D>,
93 runtime: RuntimeHandle,
94 last_sample_ms: Cell<Option<i64>>,
95}
96
97impl LiquidDragAxis {
98 fn new(initial: f32, runtime: RuntimeHandle, follow: Option<AnimationType>) -> Self {
99 Self {
100 follow,
101 animation: RefCell::new(Animatable::new(initial, runtime.clone())),
102 pointer: Cell::new(None),
103 velocity: RefCell::new(VelocityTracker1D::new()),
104 runtime,
105 last_sample_ms: Cell::new(None),
106 }
107 }
108
109 fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
110 let candidate = event_time_ms
111 .or_else(|| {
112 self.runtime
113 .last_frame_time_nanos()
114 .map(|nanos| (nanos / 1_000_000) as i64)
115 })
116 .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
117 let monotonic = self
118 .last_sample_ms
119 .get()
120 .map_or(candidate, |last| candidate.max(last + 1));
121 self.last_sample_ms.set(Some(monotonic));
122 monotonic
123 }
124
125 pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
126 let time_ms = self.sample_time_ms(event_time_ms);
127 let mut velocity = self.velocity.borrow_mut();
128 velocity.reset();
129 velocity.add_data_point(time_ms, position);
130 self.pointer.set(Some(position));
131 self.animation.borrow_mut().snapTo(position);
132 }
133
134 pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
135 self.move_to_at(
136 position,
137 event_time_ms,
138 self.runtime.last_frame_time_nanos(),
139 );
140 }
141
142 fn move_to_at(
143 &self,
144 position: f32,
145 event_time_ms: Option<i64>,
146 animation_time_nanos: Option<u64>,
147 ) {
148 if self.pointer.get().is_none() {
149 return;
150 }
151 let time_ms = self.sample_time_ms(event_time_ms);
152 self.velocity.borrow_mut().add_data_point(time_ms, position);
153 self.pointer.set(Some(position));
154 if let Some(animation) = self.follow {
155 let mut value = self.animation.borrow_mut();
156 if let Some(time) = animation_time_nanos {
157 value.animate_to_at(position, animation, time);
158 } else {
159 value.animateTo(position, animation);
160 }
161 } else {
162 self.animation.borrow_mut().snapTo(position);
163 }
164 }
165
166 pub(crate) fn release_to(
167 &self,
168 target: f32,
169 event_time_ms: Option<i64>,
170 animation: AnimationType,
171 ) {
172 self.release_to_at(
173 target,
174 event_time_ms,
175 animation,
176 self.runtime.last_frame_time_nanos(),
177 );
178 }
179
180 fn release_to_at(
181 &self,
182 target: f32,
183 event_time_ms: Option<i64>,
184 animation: AnimationType,
185 animation_time_nanos: Option<u64>,
186 ) {
187 let Some(position) = self.pointer.take() else {
188 self.settle_to_at(target, animation, animation_time_nanos);
189 return;
190 };
191 let time_ms = self.sample_time_ms(event_time_ms);
192 self.velocity.borrow_mut().add_data_point(time_ms, position);
193 let release_velocity = if self.follow.is_some() {
194 self.animation.borrow().velocity()
195 } else {
196 self.velocity.borrow().calculate_velocity_with_max(8_000.0)
197 };
198 let mut value = self.animation.borrow_mut();
199 if let Some(time) = animation_time_nanos {
200 if self.follow.is_some() {
201 value.animate_to_at(target, animation, time);
202 } else {
203 value.animate_to_with_velocity_at(target, release_velocity, animation, time);
204 }
205 } else {
206 value.animate_to_with_velocity(target, release_velocity, animation);
207 }
208 }
209
210 pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
211 let Some(_) = self.pointer.get() else {
212 self.animation.borrow_mut().snapTo(position);
213 return;
214 };
215 let time_ms = self.sample_time_ms(event_time_ms);
216 self.velocity.borrow_mut().add_data_point(time_ms, position);
217 self.pointer.set(None);
218 self.animation.borrow_mut().snapTo(position);
219 }
220
221 pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
222 self.settle_to_at(target, animation, self.runtime.last_frame_time_nanos());
223 }
224
225 fn settle_to_at(&self, target: f32, animation: AnimationType, time: Option<u64>) {
226 if self.pointer.get().is_some() {
227 return;
228 }
229 let mut value = self.animation.borrow_mut();
230 if (value.target() - target).abs() > f32::EPSILON {
231 if let Some(time) = time {
232 value.animate_to_at(target, animation, time);
233 } else {
234 value.animateTo(target, animation);
235 }
236 }
237 }
238
239 pub(crate) fn value(&self) -> f32 {
240 if self.follow.is_some() {
241 self.animation.borrow().state().value()
242 } else {
243 self.pointer
244 .get()
245 .unwrap_or_else(|| self.animation.borrow().state().value())
246 }
247 }
248
249 pub(crate) fn is_dragging(&self) -> bool {
250 self.pointer.get().is_some()
251 }
252}
253
254#[composable]
255pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
256 remember_liquid_axis(initial, None)
257}
258
259#[composable]
260pub(crate) fn remember_liquid_follow_axis(
261 initial: f32,
262 animation: AnimationType,
263) -> Rc<LiquidDragAxis> {
264 remember_liquid_axis(initial, Some(animation))
265}
266
267#[composable]
268fn remember_liquid_axis(initial: f32, follow: Option<AnimationType>) -> Rc<LiquidDragAxis> {
269 with_current_composer(|composer| {
270 let runtime = composer.runtime_handle();
271 composer
272 .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime, follow)))
273 .with(Rc::clone)
274 })
275}
276
277pub(crate) struct LiquidLensGesture {
278 pub axis: Rc<LiquidDragAxis>,
279 pub cell_width: f32,
280 pub cell_offset: f32,
281 pub count: usize,
282 pub tap_slop: f32,
283 pub drag_left: Rc<dyn Fn(f32) -> f32>,
284 pub rest_left: Rc<dyn Fn(usize) -> f32>,
285 pub selected: usize,
286 pub on_pressed: Rc<dyn Fn(bool, Option<u64>)>,
287 pub on_touch: Rc<dyn Fn(f32, f32)>,
288 pub on_select: Rc<dyn Fn(usize)>,
289}
290
291impl LiquidLensGesture {
292 fn commit_index(&self, position: f32) -> usize {
293 (((position - self.cell_offset) / self.cell_width.max(1.0)).floor() as isize)
294 .clamp(0, self.count.saturating_sub(1) as isize) as usize
295 }
296}
297
298pub(crate) async fn liquid_lens_gesture(
299 scope: cranpose_ui::PointerInputScope,
300 gesture: LiquidLensGesture,
301) {
302 use cranpose_foundation::{PointerEventKind, PointerId};
303 use cranpose_services::{HapticFeedback, default_haptics};
304
305 scope
306 .await_pointer_event_scope(|await_scope| async move {
307 let mut down_x = 0.0f32;
308 let mut moved = false;
309 let mut active_pointer = Option::<PointerId>::None;
310 loop {
311 let event = await_scope.await_pointer_event().await;
312 match event.kind {
313 PointerEventKind::Down if active_pointer.is_none() => {
314 active_pointer = Some(event.id);
315 down_x = event.position.x;
316 moved = false;
317 gesture.axis.settle_to_at(
318 (gesture.rest_left)(gesture.commit_index(down_x)),
319 LiquidMotion::glide(),
320 event.animation_time_nanos,
321 );
322 (gesture.on_pressed)(true, event.animation_time_nanos);
323 (gesture.on_touch)(event.position.x, event.position.y);
324 default_haptics().perform(HapticFeedback::Selection);
325 event.consume();
326 }
327 PointerEventKind::Move if active_pointer == Some(event.id) => {
328 moved |= (event.position.x - down_x).abs() > gesture.tap_slop;
329 if moved {
330 let target = (gesture.drag_left)(event.position.x);
331 if gesture.axis.is_dragging() {
332 gesture.axis.move_to_at(
333 target,
334 event.time_ms,
335 event.animation_time_nanos,
336 );
337 } else if (gesture.axis.value() - target).abs()
338 <= gesture.cell_width * 0.6
339 {
340 gesture.axis.begin(gesture.axis.value(), event.time_ms);
341 gesture.axis.move_to_at(
342 target,
343 event.time_ms,
344 event.animation_time_nanos,
345 );
346 } else {
347 gesture.axis.settle_to_at(
348 target,
349 LiquidMotion::glide(),
350 event.animation_time_nanos,
351 );
352 }
353 }
354 (gesture.on_touch)(event.position.x, event.position.y);
355 event.consume();
356 }
357 PointerEventKind::Up if active_pointer == Some(event.id) => {
358 active_pointer = None;
359 (gesture.on_pressed)(false, event.animation_time_nanos);
360 let commit_x = if moved { event.position.x } else { down_x };
361 let index = gesture.commit_index(commit_x);
362 gesture.axis.release_to_at(
363 (gesture.rest_left)(index),
364 event.time_ms,
365 LiquidMotion::glide(),
366 event.animation_time_nanos,
367 );
368 default_haptics().perform(HapticFeedback::ImpactLight);
369 (gesture.on_select)(index);
370 event.consume();
371 }
372 PointerEventKind::Cancel if active_pointer == Some(event.id) => {
373 active_pointer = None;
374 (gesture.on_pressed)(false, event.animation_time_nanos);
375 gesture.axis.release_to_at(
376 (gesture.rest_left)(gesture.selected),
377 event.time_ms,
378 LiquidMotion::glide(),
379 event.animation_time_nanos,
380 );
381 event.consume();
382 }
383 _ => {}
384 }
385 }
386 })
387 .await;
388}
389
390#[composable]
399pub fn liquid_press_scale(
400 modifier: Modifier,
401 interaction_source: MutableInteractionSource,
402 pressed_scale: f32,
403) -> (Modifier, State<bool>, State<f32>) {
404 let pressed = interaction_source.collectIsPressedAsState();
405 let scale = cranpose_animation::animateFloatAsState(
406 if pressed.get() {
407 pressed_scale.max(1.0)
408 } else {
409 1.0
410 },
411 LiquidMotion::snappy(),
412 "liquid-press-scale",
413 );
414 let content_alpha = cranpose_animation::animateFloatAsState(
415 if pressed.get() { 0.35 } else { 1.0 },
416 LiquidMotion::smooth(),
417 "liquid-press-content",
418 );
419 let modifier = modifier.graphics_layer(move || {
420 let scale = scale.get();
421 GraphicsLayer {
422 scale_x: scale,
423 scale_y: scale,
424 ..Default::default()
425 }
426 });
427 (modifier, pressed, content_alpha)
428}
429
430#[cfg(test)]
431#[path = "tests/motion_tests.rs"]
432mod tests;