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 cranpose_animation::{spring, tween, Animatable, AnimationType, Easing};
5use cranpose_core::{with_current_composer, RuntimeHandle, State};
6use cranpose_foundation::VelocityTracker1D;
7use cranpose_macros::composable;
8use cranpose_ui::Modifier;
9use cranpose_ui::MutableInteractionSource;
10use cranpose_ui_graphics::GraphicsLayer;
11use std::cell::{Cell, RefCell};
12use std::rc::Rc;
13
14use crate::dynamics::{LiquidDynamics, LiquidPose};
15
16const FLUID_RELAX_MS: u64 = 420;
17
18/// Named springs used across the Liquid components (value-space, velocity
19/// preserving).
20pub struct LiquidMotion;
21
22impl LiquidMotion {
23    /// Snappy interactions: presses, toggles, selection moves.
24    pub fn snappy() -> AnimationType {
25        spring(0.85, 900.0)
26    }
27
28    /// The droplet feel: visible overshoot for morphing shapes.
29    pub fn bouncy() -> AnimationType {
30        spring(0.55, 500.0)
31    }
32
33    /// Gentle settle for large surfaces (sheets, menus).
34    pub fn smooth() -> AnimationType {
35        spring(1.0, 400.0)
36    }
37
38    /// The leading edge of a stretching selection blob (runs ahead).
39    pub fn blob_leading() -> AnimationType {
40        spring(0.8, 900.0)
41    }
42
43    /// The trailing edge of a stretching selection blob (drags behind, giving
44    /// the droplet elongation while in motion).
45    pub fn blob_trailing() -> AnimationType {
46        spring(0.9, 380.0)
47    }
48
49    /// A released lens flying to its committed slot: the reference tab-bar
50    /// transit crosses ~3 cells in ~330 ms (measured from the iphone17
51    /// recording) — much gentler than the finger-chase spring.
52    pub fn glide() -> AnimationType {
53        spring(1.0, 120.0)
54    }
55}
56
57/// One travelling coordinate with strict direct-manipulation semantics.
58/// Pointer samples are visible immediately; the animation channel is used
59/// only after release or for controlled-state changes while idle.
60pub(crate) struct LiquidDragAxis {
61    animation: RefCell<Animatable<f32>>,
62    pointer: Cell<Option<f32>>,
63    velocity: RefCell<VelocityTracker1D>,
64    runtime: RuntimeHandle,
65    last_sample_ms: Cell<Option<i64>>,
66    dynamics: LiquidDynamics,
67    fluid_clock: RefCell<Animatable<f32>>,
68}
69
70impl LiquidDragAxis {
71    fn new(initial: f32, runtime: RuntimeHandle) -> Self {
72        Self {
73            animation: RefCell::new(Animatable::new(initial, runtime.clone())),
74            pointer: Cell::new(None),
75            velocity: RefCell::new(VelocityTracker1D::new()),
76            dynamics: LiquidDynamics::new(runtime.clone()),
77            fluid_clock: RefCell::new(Animatable::new(1.0, runtime.clone())),
78            runtime,
79            last_sample_ms: Cell::new(None),
80        }
81    }
82
83    fn arm_fluid_frames(&self) {
84        let mut clock = self.fluid_clock.borrow_mut();
85        clock.snapTo(0.0);
86        clock.animateTo(1.0, tween(FLUID_RELAX_MS, Easing::LinearEasing));
87    }
88
89    fn sample_time_ms(&self, event_time_ms: Option<i64>) -> i64 {
90        let candidate = event_time_ms
91            .or_else(|| {
92                self.runtime
93                    .last_frame_time_nanos()
94                    .map(|nanos| (nanos / 1_000_000) as i64)
95            })
96            .unwrap_or_else(|| self.last_sample_ms.get().unwrap_or(0) + 16);
97        let monotonic = self
98            .last_sample_ms
99            .get()
100            .map_or(candidate, |last| candidate.max(last + 1));
101        self.last_sample_ms.set(Some(monotonic));
102        monotonic
103    }
104
105    pub(crate) fn begin(&self, position: f32, event_time_ms: Option<i64>) {
106        let time_ms = self.sample_time_ms(event_time_ms);
107        let mut velocity = self.velocity.borrow_mut();
108        velocity.reset();
109        velocity.add_data_point(time_ms, position);
110        self.pointer.set(Some(position));
111        self.animation.borrow_mut().snapTo(position);
112        self.dynamics.anchor_pointer((position, 0.0));
113        self.arm_fluid_frames();
114    }
115
116    pub(crate) fn move_to(&self, position: f32, event_time_ms: Option<i64>) {
117        if self.pointer.get().is_none() {
118            return;
119        }
120        let previous_time_ms = self.last_sample_ms.get();
121        let time_ms = self.sample_time_ms(event_time_ms);
122        self.velocity.borrow_mut().add_data_point(time_ms, position);
123        self.pointer.set(Some(position));
124        self.animation.borrow_mut().snapTo(position);
125        if let Some(previous_time_ms) = previous_time_ms {
126            let dt = (time_ms - previous_time_ms).max(1) as f32 / 1000.0;
127            self.dynamics.advance_pointer((position, 0.0), dt);
128        }
129        self.arm_fluid_frames();
130    }
131
132    pub(crate) fn release_to(
133        &self,
134        target: f32,
135        event_time_ms: Option<i64>,
136        animation: AnimationType,
137    ) {
138        let Some(position) = self.pointer.take() else {
139            self.settle_to(target, animation);
140            return;
141        };
142        let time_ms = self.sample_time_ms(event_time_ms);
143        self.velocity.borrow_mut().add_data_point(time_ms, position);
144        let release_velocity = self.velocity.borrow().calculate_velocity_with_max(8_000.0);
145        self.dynamics.release_pointer();
146        self.animation
147            .borrow_mut()
148            .animate_to_with_velocity(target, release_velocity, animation);
149    }
150
151    pub(crate) fn settle_to(&self, target: f32, animation: AnimationType) {
152        if self.pointer.get().is_some() {
153            return;
154        }
155        let mut value = self.animation.borrow_mut();
156        if (value.target() - target).abs() > f32::EPSILON {
157            value.animateTo(target, animation);
158        }
159    }
160
161    pub(crate) fn value(&self) -> f32 {
162        let _ = self.fluid_clock.borrow().state().value();
163        self.pointer
164            .get()
165            .unwrap_or_else(|| self.animation.borrow().state().value())
166    }
167
168    pub(crate) fn liquid_pose(&self) -> LiquidPose {
169        self.dynamics.update_pointer((self.value(), 0.0))
170    }
171
172    pub(crate) fn is_dragging(&self) -> bool {
173        self.pointer.get().is_some()
174    }
175}
176
177#[composable]
178pub(crate) fn remember_liquid_drag_axis(initial: f32) -> Rc<LiquidDragAxis> {
179    with_current_composer(|composer| {
180        let runtime = composer.runtime_handle();
181        composer
182            .remember(move || Rc::new(LiquidDragAxis::new(initial, runtime)))
183            .with(Rc::clone)
184    })
185}
186
187/// Press feedback for glass controls, per the Liquid Glass law: touched glass
188/// GROWS (spring scale toward `pressed_scale` — never smaller) and turns MORE
189/// TRANSPARENT (the returned content alpha dips while pressed, the reference
190/// "…" dots fading as the button lifts). Returns the pressed state so callers
191/// can also boost the specular highlight.
192///
193/// Apply the returned modifier *outside* the glass effect so the whole lens
194/// scales together; apply the content alpha to the label/icon layer.
195#[composable]
196pub fn liquid_press_scale(
197    modifier: Modifier,
198    interaction_source: MutableInteractionSource,
199    pressed_scale: f32,
200) -> (Modifier, State<bool>, State<f32>) {
201    let pressed = interaction_source.collectIsPressedAsState();
202    let scale = cranpose_animation::animateFloatAsState(
203        if pressed.get() {
204            pressed_scale.max(1.0)
205        } else {
206            1.0
207        },
208        LiquidMotion::snappy(),
209        "liquid-press-scale",
210    );
211    let content_alpha = cranpose_animation::animateFloatAsState(
212        // The reference down-state ghosts glyphs hard (the menu button's
213        // dots drop to ~30% while held).
214        if pressed.get() { 0.35 } else { 1.0 },
215        LiquidMotion::smooth(),
216        "liquid-press-content",
217    );
218    let modifier = modifier.graphics_layer(move || {
219        let scale = scale.get();
220        GraphicsLayer {
221            scale_x: scale,
222            scale_y: scale,
223            ..Default::default()
224        }
225    });
226    (modifier, pressed, content_alpha)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn axis(initial: f32) -> (cranpose_core::Runtime, LiquidDragAxis) {
234        let runtime =
235            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
236        let axis = LiquidDragAxis::new(initial, runtime.handle());
237        (runtime, axis)
238    }
239
240    #[test]
241    fn pointer_samples_are_the_visual_coordinate_without_a_chase() {
242        let (_runtime, axis) = axis(10.0);
243        axis.begin(20.0, Some(0));
244        assert_eq!(axis.value(), 20.0);
245        axis.move_to(180.0, Some(16));
246        assert_eq!(axis.value(), 180.0);
247    }
248
249    #[test]
250    fn pointer_sample_excites_the_incompressible_pose_before_render() {
251        let (_runtime, axis) = axis(0.0);
252        axis.begin(0.0, Some(0));
253        axis.move_to(14.0, Some(16));
254        let pose = axis.liquid_pose();
255        let deformation = (pose.stretch - 1.0).abs();
256        assert!(
257            (0.03..=0.08).contains(&deformation),
258            "the direct-input frame must deform visibly without treating one sample as extreme acceleration: {pose:?}"
259        );
260        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
261        assert_eq!(axis.value(), 14.0);
262    }
263
264    #[test]
265    fn render_without_a_new_pointer_sample_preserves_velocity_continuity() {
266        let (_runtime, axis) = axis(0.0);
267        axis.runtime.drain_frame_callbacks(1_000_000);
268        axis.begin(0.0, Some(0));
269        axis.move_to(14.0, Some(16));
270        let sampled = axis.liquid_pose();
271
272        axis.runtime.drain_frame_callbacks(17_000_000);
273        let next_frame = axis.liquid_pose();
274
275        assert!(
276            (next_frame.stretch - sampled.stretch).abs() < 0.08,
277            "a render frame without input must not synthesize a brake impulse: {sampled:?} -> {next_frame:?}"
278        );
279        assert!((next_frame.stretch * next_frame.ortho - 1.0).abs() < 1e-4);
280    }
281
282    #[test]
283    fn controlled_retargets_wait_until_direct_manipulation_ends() {
284        let (_runtime, axis) = axis(10.0);
285        axis.begin(40.0, Some(0));
286        axis.settle_to(90.0, LiquidMotion::snappy());
287        assert_eq!(axis.value(), 40.0);
288        axis.release_to(90.0, Some(16), LiquidMotion::snappy());
289        assert!(!axis.is_dragging());
290        assert_eq!(axis.animation.borrow().target(), 90.0);
291    }
292
293    #[test]
294    fn released_flight_is_critically_damped() {
295        let AnimationType::Spring(spec) = LiquidMotion::glide() else {
296            panic!("released flight must use a spring");
297        };
298        assert_eq!(spec.damping_ratio, 1.0);
299        assert_eq!(spec.stiffness, 120.0);
300    }
301}