1use 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
18pub struct LiquidMotion;
21
22impl LiquidMotion {
23 pub fn snappy() -> AnimationType {
25 spring(0.85, 900.0)
26 }
27
28 pub fn bouncy() -> AnimationType {
30 spring(0.55, 500.0)
31 }
32
33 pub fn smooth() -> AnimationType {
35 spring(1.0, 400.0)
36 }
37
38 pub fn blob_leading() -> AnimationType {
40 spring(0.8, 900.0)
41 }
42
43 pub fn blob_trailing() -> AnimationType {
46 spring(0.9, 380.0)
47 }
48
49 pub fn glide() -> AnimationType {
53 spring(1.0, 120.0)
54 }
55}
56
57pub(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#[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 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}