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, 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
48/// Named springs used across the Liquid components (value-space, velocity
49/// preserving).
50pub struct LiquidMotion;
51
52impl LiquidMotion {
53    /// Snappy interactions: presses, toggles, selection moves.
54    pub fn snappy() -> AnimationType {
55        spring(0.85, 900.0)
56    }
57
58    /// The droplet feel: visible overshoot for morphing shapes.
59    pub fn bouncy() -> AnimationType {
60        spring(0.55, 500.0)
61    }
62
63    /// Gentle settle for large surfaces (sheets, menus).
64    pub fn smooth() -> AnimationType {
65        spring(1.0, 400.0)
66    }
67
68    /// The leading edge of a stretching selection blob (runs ahead).
69    pub fn blob_leading() -> AnimationType {
70        spring(0.8, 900.0)
71    }
72
73    /// The trailing edge of a stretching selection blob (drags behind, giving
74    /// the droplet elongation while in motion).
75    pub fn blob_trailing() -> AnimationType {
76        spring(0.9, 380.0)
77    }
78
79    /// A released lens flying to its committed slot: the reference
80    /// bottom-bar transfer arrives in ~170 ms (on-white-click sheet,
81    /// f_0040 departure to f_0050 arrival at 60 fps) with no visible
82    /// overshoot; the optical settle continues after the geometry lands.
83    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/// Press feedback for glass controls, per the Liquid Glass law: touched glass
391/// GROWS (spring scale toward `pressed_scale` — never smaller) and turns MORE
392/// TRANSPARENT (the returned content alpha dips while pressed, the reference
393/// "…" dots fading as the button lifts). Returns the pressed state so callers
394/// can also boost the specular highlight.
395///
396/// Apply the returned modifier *outside* the glass effect so the whole lens
397/// scales together; apply the content alpha to the label/icon layer.
398#[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;