Skip to main content

cranpose_liquid/
motion.rs

1//! Liquid motion: the spring presets every component shares, and the press
2//! interaction (scale + specular boost) that makes glass feel physical.
3
4use std::{
5    cell::{Cell, RefCell},
6    rc::Rc,
7};
8
9use cranpose_animation::{Animatable, AnimationType, Easing, spring, tween};
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
16use crate::dynamics::{LiquidDynamics, LiquidPose};
17
18const FLUID_RELAX_MS: u64 = 420;
19const VISUAL_HANDOFF_TOLERANCE_IN_ITEMS: f32 = 0.12;
20
21pub(crate) fn liquid_visual_index(
22    selected: usize,
23    lens_position: f32,
24    item_width: f32,
25    count: usize,
26    lens_owns_selection: bool,
27) -> usize {
28    if count == 0 {
29        return 0;
30    }
31    let selected = selected.min(count - 1);
32    if !lens_owns_selection || !lens_position.is_finite() || item_width <= f32::EPSILON {
33        return selected;
34    }
35    ((lens_position + item_width * 0.5) / item_width)
36        .floor()
37        .clamp(0.0, count.saturating_sub(1) as f32) as usize
38}
39
40pub(crate) fn liquid_axis_owns_visual_selection(
41    direct: bool,
42    lens_position: f32,
43    state_position: f32,
44    item_width: f32,
45) -> bool {
46    direct
47        || (lens_position - state_position).abs()
48            > item_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_ITEMS
49}
50
51/// Named springs used across the Liquid components (value-space, velocity
52/// preserving).
53pub struct LiquidMotion;
54
55impl LiquidMotion {
56    /// Snappy interactions: presses, toggles, selection moves.
57    pub fn snappy() -> AnimationType {
58        spring(0.85, 900.0)
59    }
60
61    /// The droplet feel: visible overshoot for morphing shapes.
62    pub fn bouncy() -> AnimationType {
63        spring(0.55, 500.0)
64    }
65
66    /// Gentle settle for large surfaces (sheets, menus).
67    pub fn smooth() -> AnimationType {
68        spring(1.0, 400.0)
69    }
70
71    /// The leading edge of a stretching selection blob (runs ahead).
72    pub fn blob_leading() -> AnimationType {
73        spring(0.8, 900.0)
74    }
75
76    /// The trailing edge of a stretching selection blob (drags behind, giving
77    /// the droplet elongation while in motion).
78    pub fn blob_trailing() -> AnimationType {
79        spring(0.9, 380.0)
80    }
81
82    /// A released lens flying to its committed slot: the reference
83    /// bottom-bar transfer arrives in ~170 ms (on-white-click sheet,
84    /// f_0040 departure to f_0050 arrival at 60 fps) with no visible
85    /// overshoot; the optical settle continues after the geometry lands.
86    pub fn glide() -> AnimationType {
87        spring(1.0, 500.0)
88    }
89}
90
91pub(crate) struct LiquidDragAxis {
92    animation: RefCell<Animatable<f32>>,
93    follow: Option<AnimationType>,
94    pointer: Cell<Option<f32>>,
95    velocity: RefCell<VelocityTracker1D>,
96    runtime: RuntimeHandle,
97    last_sample_ms: Cell<Option<i64>>,
98    dynamics: LiquidDynamics,
99    fluid_clock: RefCell<Animatable<f32>>,
100}
101
102impl LiquidDragAxis {
103    fn new(initial: f32, runtime: RuntimeHandle, follow: Option<AnimationType>) -> Self {
104        Self {
105            follow,
106            animation: RefCell::new(Animatable::new(initial, runtime.clone())),
107            pointer: Cell::new(None),
108            velocity: RefCell::new(VelocityTracker1D::new()),
109            dynamics: LiquidDynamics::new(runtime.clone()),
110            fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
111            runtime,
112            last_sample_ms: Cell::new(None),
113        }
114    }
115
116    fn arm_fluid_frames(&self) {
117        let mut clock = self.fluid_clock.borrow_mut();
118        clock.snapTo(0.0);
119        clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
120    }
121
122    fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
123        let candidate = event_time_ms
124            .or_else(|| {
125                self.runtime
126                    .last_frame_time_nanos()
127                    .map(|nanos| (nanos / 1_000_000) as i64)
128            })
129            .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
130        let monotonic = self
131            .last_sample_ms
132            .get()
133            .map_or(candidate, |last| candidate.max(last + 1));
134        self.last_sample_ms.set(Some(monotonic));
135        monotonic
136    }
137
138    pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
139        let time_ms = self.sample_time_ms(event_time_ms);
140        let mut velocity = self.velocity.borrow_mut();
141        velocity.reset();
142        velocity.add_data_point(time_ms, position);
143        self.pointer.set(Some(position));
144        self.animation.borrow_mut().snapTo(position);
145        self.dynamics.anchor_pointer((position, 0.0));
146        self.arm_fluid_frames();
147    }
148
149    pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
150        self.move_to_at(
151            position,
152            event_time_ms,
153            self.runtime.last_frame_time_nanos(),
154        );
155    }
156
157    fn move_to_at(
158        &self,
159        position: f32,
160        event_time_ms: Option<i64>,
161        animation_time_nanos: Option<u64>,
162    ) {
163        if self.pointer.get().is_none() {
164            return;
165        }
166        let previous_time_ms = self.last_sample_ms.get();
167        let time_ms = self.sample_time_ms(event_time_ms);
168        self.velocity.borrow_mut().add_data_point(time_ms, position);
169        self.pointer.set(Some(position));
170        if let Some(animation) = self.follow {
171            let mut value = self.animation.borrow_mut();
172            if let Some(time) = animation_time_nanos {
173                value.animate_to_at(position, animation, time);
174            } else {
175                value.animateTo(position, animation);
176            }
177        } else {
178            self.animation.borrow_mut().snapTo(position);
179        }
180        if let Some(previous_time_ms) = previous_time_ms.filter(|_| self.follow.is_none()) {
181            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
182            self.dynamics.advance_pointer((position, 0.0), dt);
183        }
184        self.arm_fluid_frames();
185    }
186
187    pub(crate) fn release_to(
188        &self,
189        target: f32,
190        event_time_ms: Option<i64>,
191        animation: AnimationType,
192    ) {
193        self.release_to_at(
194            target,
195            event_time_ms,
196            animation,
197            self.runtime.last_frame_time_nanos(),
198        );
199    }
200
201    fn release_to_at(
202        &self,
203        target: f32,
204        event_time_ms: Option<i64>,
205        animation: AnimationType,
206        animation_time_nanos: Option<u64>,
207    ) {
208        let Some(position) = self.pointer.take() else {
209            self.settle_to_at(target, animation, animation_time_nanos);
210            return;
211        };
212        let time_ms = self.sample_time_ms(event_time_ms);
213        self.velocity.borrow_mut().add_data_point(time_ms, position);
214        let release_velocity = if self.follow.is_some() {
215            self.animation.borrow().velocity()
216        } else {
217            self.velocity.borrow().calculate_velocity_with_max(8_000.0)
218        };
219        self.dynamics.release_pointer();
220        let mut value = self.animation.borrow_mut();
221        if let Some(time) = animation_time_nanos {
222            if self.follow.is_some() {
223                value.animate_to_at(target, animation, time);
224            } else {
225                value.animate_to_with_velocity_at(target, release_velocity, animation, time);
226            }
227        } else {
228            value.animate_to_with_velocity(target, release_velocity, animation);
229        }
230    }
231
232    pub(crate) fn finish_at(&self, position: f32, event_time_ms: Option<i64>) {
233        let Some(_) = self.pointer.get() else {
234            self.animation.borrow_mut().snapTo(position);
235            return;
236        };
237        let previous_time_ms = self.last_sample_ms.get();
238        let time_ms = self.sample_time_ms(event_time_ms);
239        self.velocity.borrow_mut().add_data_point(time_ms, position);
240        if let Some(previous_time_ms) = previous_time_ms {
241            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
242            self.dynamics.advance_pointer((position, 0.0), dt);
243        }
244        self.pointer.set(None);
245        self.animation.borrow_mut().snapTo(position);
246        self.dynamics.release_pointer();
247        self.arm_fluid_frames();
248    }
249
250    pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
251        self.settle_to_at(target, animation, self.runtime.last_frame_time_nanos());
252    }
253
254    fn settle_to_at(&self, target: f32, animation: AnimationType, time: Option<u64>) {
255        if self.pointer.get().is_some() {
256            return;
257        }
258        let mut value = self.animation.borrow_mut();
259        if (value.target() - target).abs() > f32::EPSILON {
260            if let Some(time) = time {
261                value.animate_to_at(target, animation, time);
262            } else {
263                value.animateTo(target, animation);
264            }
265        }
266    }
267
268    pub(crate) fn value(&self) -> f32 {
269        let _ = self.fluid_clock.borrow().state().value();
270        if self.follow.is_some() {
271            self.animation.borrow().state().value()
272        } else {
273            self.pointer
274                .get()
275                .unwrap_or_else(|| self.animation.borrow().state().value())
276        }
277    }
278
279    pub(crate) fn liquid_pose(&self) -> LiquidPose {
280        self.dynamics.update_pointer((self.value(), 0.0))
281    }
282
283    pub(crate) fn is_dragging(&self) -> bool {
284        self.pointer.get().is_some()
285    }
286}
287
288#[composable]
289pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
290    remember_liquid_axis(initial, None)
291}
292
293#[composable]
294pub(crate) fn remember_liquid_follow_axis(
295    initial: f32,
296    animation: AnimationType,
297) -> Rc<LiquidDragAxis> {
298    remember_liquid_axis(initial, Some(animation))
299}
300
301#[composable]
302fn remember_liquid_axis(initial: f32, follow: Option<AnimationType>) -> Rc<LiquidDragAxis> {
303    with_current_composer(|composer| {
304        let runtime = composer.runtime_handle();
305        composer
306            .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime, follow)))
307            .with(Rc::clone)
308    })
309}
310
311pub(crate) struct LiquidLensGesture {
312    pub axis: Rc<LiquidDragAxis>,
313    pub cell_width: f32,
314    pub cell_offset: f32,
315    pub count: usize,
316    pub tap_slop: f32,
317    pub drag_left: Rc<dyn Fn(f32) -> f32>,
318    pub rest_left: Rc<dyn Fn(usize) -> f32>,
319    pub selected: usize,
320    pub on_pressed: Rc<dyn Fn(bool, Option<u64>)>,
321    pub on_touch: Rc<dyn Fn(f32, f32)>,
322    pub on_select: Rc<dyn Fn(usize)>,
323}
324
325impl LiquidLensGesture {
326    fn commit_index(&self, position: f32) -> usize {
327        (((position - self.cell_offset) / self.cell_width.max(1.0)).floor() as isize)
328            .clamp(0, self.count.saturating_sub(1) as isize) as usize
329    }
330}
331
332pub(crate) async fn liquid_lens_gesture(
333    scope: cranpose_ui::PointerInputScope,
334    gesture: LiquidLensGesture,
335) {
336    use cranpose_foundation::{PointerEventKind, PointerId};
337    use cranpose_services::{HapticFeedback, default_haptics};
338
339    scope
340        .await_pointer_event_scope(|await_scope| async move {
341            let mut down_x = 0.0f32;
342            let mut moved = false;
343            let mut active_pointer = Option::<PointerId>::None;
344            loop {
345                let event = await_scope.await_pointer_event().await;
346                match event.kind {
347                    PointerEventKind::Down if active_pointer.is_none() => {
348                        active_pointer = Some(event.id);
349                        down_x = event.position.x;
350                        moved = false;
351                        gesture.axis.settle_to_at(
352                            (gesture.rest_left)(gesture.commit_index(down_x)),
353                            LiquidMotion::glide(),
354                            event.animation_time_nanos,
355                        );
356                        (gesture.on_pressed)(true, event.animation_time_nanos);
357                        (gesture.on_touch)(event.position.x, event.position.y);
358                        default_haptics().perform(HapticFeedback::Selection);
359                        event.consume();
360                    }
361                    PointerEventKind::Move if active_pointer == Some(event.id) => {
362                        moved |= (event.position.x - down_x).abs() > gesture.tap_slop;
363                        if moved {
364                            let target = (gesture.drag_left)(event.position.x);
365                            if gesture.axis.is_dragging() {
366                                gesture.axis.move_to_at(
367                                    target,
368                                    event.time_ms,
369                                    event.animation_time_nanos,
370                                );
371                            } else if (gesture.axis.value() - target).abs()
372                                <= gesture.cell_width * 0.6
373                            {
374                                gesture.axis.begin(gesture.axis.value(), event.time_ms);
375                                gesture.axis.move_to_at(
376                                    target,
377                                    event.time_ms,
378                                    event.animation_time_nanos,
379                                );
380                            } else {
381                                gesture.axis.settle_to_at(
382                                    target,
383                                    LiquidMotion::glide(),
384                                    event.animation_time_nanos,
385                                );
386                            }
387                        }
388                        (gesture.on_touch)(event.position.x, event.position.y);
389                        event.consume();
390                    }
391                    PointerEventKind::Up if active_pointer == Some(event.id) => {
392                        active_pointer = None;
393                        (gesture.on_pressed)(false, event.animation_time_nanos);
394                        let commit_x = if moved { event.position.x } else { down_x };
395                        let index = gesture.commit_index(commit_x);
396                        gesture.axis.release_to_at(
397                            (gesture.rest_left)(index),
398                            event.time_ms,
399                            LiquidMotion::glide(),
400                            event.animation_time_nanos,
401                        );
402                        default_haptics().perform(HapticFeedback::ImpactLight);
403                        (gesture.on_select)(index);
404                        event.consume();
405                    }
406                    PointerEventKind::Cancel if active_pointer == Some(event.id) => {
407                        active_pointer = None;
408                        (gesture.on_pressed)(false, event.animation_time_nanos);
409                        gesture.axis.release_to_at(
410                            (gesture.rest_left)(gesture.selected),
411                            event.time_ms,
412                            LiquidMotion::glide(),
413                            event.animation_time_nanos,
414                        );
415                        event.consume();
416                    }
417                    _ => {}
418                }
419            }
420        })
421        .await;
422}
423
424/// Press feedback for glass controls, per the Liquid Glass law: touched glass
425/// GROWS (spring scale toward `pressed_scale` — never smaller) and turns MORE
426/// TRANSPARENT (the returned content alpha dips while pressed, the reference
427/// "…" dots fading as the button lifts). Returns the pressed state so callers
428/// can also boost the specular highlight.
429///
430/// Apply the returned modifier *outside* the glass effect so the whole lens
431/// scales together; apply the content alpha to the label/icon layer.
432#[composable]
433pub fn liquid_press_scale(
434    modifier: Modifier,
435    interaction_source: MutableInteractionSource,
436    pressed_scale: f32,
437) -> (Modifier, State<bool>, State<f32>) {
438    let pressed = interaction_source.collectIsPressedAsState();
439    let scale = cranpose_animation::animateFloatAsState(
440        if pressed.get() {
441            pressed_scale.max(1.0)
442        } else {
443            1.0
444        },
445        LiquidMotion::snappy(),
446        "liquid-press-scale",
447    );
448    let content_alpha = cranpose_animation::animateFloatAsState(
449        if pressed.get() { 0.35 } else { 1.0 },
450        LiquidMotion::smooth(),
451        "liquid-press-content",
452    );
453    let modifier = modifier.graphics_layer(move || {
454        let scale = scale.get();
455        GraphicsLayer {
456            scale_x: scale,
457            scale_y: scale,
458            ..Default::default()
459        }
460    });
461    (modifier, pressed, content_alpha)
462}
463
464#[cfg(test)]
465#[path = "tests/motion_tests.rs"]
466mod tests;