Skip to main content

cranpose_animation/
animation.rs

1//! Animation system for Cranpose
2//!
3//! Provides time-based animations with easing curves and spring physics.
4//!
5//! Note: This module uses camelCase for method names (animateTo, snapTo) to maintain
6//! 1:1 API parity with Jetpack Compose.
7
8#![allow(non_snake_case)]
9#![allow(non_upper_case_globals)]
10
11use std::cell::{Cell, RefCell};
12use std::marker::PhantomData;
13use std::rc::{Rc, Weak};
14
15use cranpose_core::internal::FrameCallbackRegistration;
16use cranpose_core::{
17    with_current_composer, DisposableEffectResult, Owned, OwnedMutableState, RuntimeHandle,
18    SideEffect, State,
19};
20
21/// Trait for types that can be linearly interpolated.
22pub trait Lerp {
23    fn lerp(&self, target: &Self, fraction: f32) -> Self;
24}
25
26impl Lerp for f32 {
27    fn lerp(&self, target: &Self, fraction: f32) -> Self {
28        self + (target - self) * fraction
29    }
30}
31
32impl Lerp for f64 {
33    fn lerp(&self, target: &Self, fraction: f32) -> Self {
34        self + (target - self) * fraction as f64
35    }
36}
37
38/// Values springs can animate: fixed-dimension float vectors (at most
39/// [`SPRING_MAX_DIMENSIONS`]), mirroring Compose's `AnimationVector1D..4D`.
40///
41/// The spring integrates each dimension independently **in value space** —
42/// velocity is expressed in value units per second — so retargeting an
43/// animation mid-flight keeps the physical velocity (no hitch), and gestures
44/// can hand their release velocity to [`Animatable::animate_to_with_velocity`].
45pub trait SpringScalar: Lerp + Clone {
46    /// Number of animated dimensions (1..=4).
47    const DIMENSIONS: usize;
48
49    /// Reads dimension `index` (`index < Self::DIMENSIONS`).
50    fn dimension(&self, index: usize) -> f32;
51
52    /// Rebuilds a value from per-dimension floats (indices beyond
53    /// [`Self::DIMENSIONS`] are ignored).
54    fn from_dimensions(dimensions: [f32; SPRING_MAX_DIMENSIONS]) -> Self;
55}
56
57/// Upper bound on [`SpringScalar::DIMENSIONS`].
58pub const SPRING_MAX_DIMENSIONS: usize = 4;
59
60/// Advances one damped-harmonic-oscillator dimension by `dt` seconds using the
61/// closed-form solution for unit mass (`ω = √stiffness`, damping `c = 2ζω`).
62/// Returns the new `(value, velocity)`; exact for any `dt`, so springs stay
63/// correct across dropped frames and long pauses.
64pub fn advance_spring(
65    value: f32,
66    velocity: f32,
67    target: f32,
68    damping_ratio: f32,
69    stiffness: f32,
70    dt: f32,
71) -> (f32, f32) {
72    let omega = stiffness.max(f32::EPSILON).sqrt();
73    let zeta = damping_ratio.max(0.0);
74    let displacement = value - target;
75
76    if (zeta - 1.0).abs() < 1e-4 {
77        // Critically damped: x(t) = (c1 + c2·t)·e^(−ωt)
78        let c1 = displacement;
79        let c2 = velocity + omega * displacement;
80        let decay = (-omega * dt).exp();
81        let next_displacement = (c1 + c2 * dt) * decay;
82        let next_velocity = (c2 - omega * (c1 + c2 * dt)) * decay;
83        (target + next_displacement, next_velocity)
84    } else if zeta < 1.0 {
85        // Underdamped: decaying oscillation at ω_d = ω·√(1−ζ²).
86        let omega_d = omega * (1.0 - zeta * zeta).sqrt();
87        let decay = (-zeta * omega * dt).exp();
88        let (sin, cos) = (omega_d * dt).sin_cos();
89        let a = displacement;
90        let b = (velocity + zeta * omega * displacement) / omega_d;
91        let next_displacement = decay * (a * cos + b * sin);
92        let next_velocity = decay
93            * ((b * omega_d - a * zeta * omega) * cos - (a * omega_d + b * zeta * omega) * sin);
94        (target + next_displacement, next_velocity)
95    } else {
96        // Overdamped: sum of two decaying exponentials.
97        let root = (zeta * zeta - 1.0).sqrt();
98        let r1 = -omega * (zeta - root);
99        let r2 = -omega * (zeta + root);
100        let c2 = (velocity - r1 * displacement) / (r2 - r1);
101        let c1 = displacement - c2;
102        let e1 = (r1 * dt).exp();
103        let e2 = (r2 * dt).exp();
104        (target + c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2)
105    }
106}
107
108impl SpringScalar for f32 {
109    const DIMENSIONS: usize = 1;
110
111    fn dimension(&self, _index: usize) -> f32 {
112        *self
113    }
114
115    fn from_dimensions(dimensions: [f32; SPRING_MAX_DIMENSIONS]) -> Self {
116        dimensions[0]
117    }
118}
119
120impl SpringScalar for f64 {
121    const DIMENSIONS: usize = 1;
122
123    fn dimension(&self, _index: usize) -> f32 {
124        *self as f32
125    }
126
127    fn from_dimensions(dimensions: [f32; SPRING_MAX_DIMENSIONS]) -> Self {
128        f64::from(dimensions[0])
129    }
130}
131
132/// Easing functions for animations matching Jetpack Compose.
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub enum Easing {
135    /// Linear interpolation (no easing).
136    /// Jetpack Compose: LinearEasing
137    LinearEasing,
138    /// Ease in using cubic curve.
139    /// Jetpack Compose: EaseIn (not a standard constant, but supported)
140    EaseIn,
141    /// Ease out using cubic curve.
142    /// Jetpack Compose: EaseOut (not a standard constant, but supported)
143    EaseOut,
144    /// Ease in and out using cubic curve.
145    /// Jetpack Compose: EaseInOut (not a standard constant, but supported)
146    EaseInOut,
147    /// Fast out, slow in (material design standard).
148    /// Jetpack Compose: FastOutSlowInEasing
149    FastOutSlowInEasing,
150    /// Linear out, slow in (material design).
151    /// Jetpack Compose: LinearOutSlowInEasing
152    LinearOutSlowInEasing,
153    /// Fast out, linear in (material design).
154    /// Jetpack Compose: FastOutLinearEasing
155    FastOutLinearEasing,
156}
157
158impl Easing {
159    /// Apply the easing function to a linear fraction [0, 1].
160    pub fn transform(&self, fraction: f32) -> f32 {
161        match self {
162            Easing::LinearEasing => fraction,
163            Easing::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, fraction),
164            Easing::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, fraction),
165            Easing::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, fraction),
166            Easing::FastOutSlowInEasing => cubic_bezier(0.4, 0.0, 0.2, 1.0, fraction),
167            Easing::LinearOutSlowInEasing => cubic_bezier(0.0, 0.0, 0.2, 1.0, fraction),
168            Easing::FastOutLinearEasing => cubic_bezier(0.4, 0.0, 1.0, 1.0, fraction),
169        }
170    }
171}
172
173/// Cubic bezier curve approximation for easing.
174fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, fraction: f32) -> f32 {
175    if fraction <= 0.0 {
176        return 0.0;
177    }
178    if fraction >= 1.0 {
179        return 1.0;
180    }
181
182    let cx = 3.0 * x1;
183    let bx = 3.0 * (x2 - x1) - cx;
184    let ax = 1.0 - cx - bx;
185
186    let cy = 3.0 * y1;
187    let by = 3.0 * (y2 - y1) - cy;
188    let ay = 1.0 - cy - by;
189
190    fn sample_curve(a: f32, b: f32, c: f32, t: f32) -> f32 {
191        ((a * t + b) * t + c) * t
192    }
193
194    fn sample_derivative(a: f32, b: f32, c: f32, t: f32) -> f32 {
195        (3.0 * a * t + 2.0 * b) * t + c
196    }
197
198    // Use Newton-Raphson iterations to solve for the parametric value `t`
199    // corresponding to the provided x fraction. Clamp to [0, 1] to keep the
200    // solution within bounds.
201    let mut t = fraction;
202    let mut newton_success = false;
203    for _ in 0..8 {
204        let x = sample_curve(ax, bx, cx, t) - fraction;
205        if x.abs() < 1e-6 {
206            newton_success = true;
207            break;
208        }
209        let dx = sample_derivative(ax, bx, cx, t);
210        if dx.abs() < 1e-6 {
211            break;
212        }
213        t = (t - x / dx).clamp(0.0, 1.0);
214    }
215
216    if !newton_success {
217        // Fall back to a binary subdivision if Newton-Raphson did not converge.
218        let mut t0 = 0.0;
219        let mut t1 = 1.0;
220        t = fraction;
221        for _ in 0..16 {
222            let x = sample_curve(ax, bx, cx, t);
223            let delta = x - fraction;
224            if delta.abs() < 1e-6 {
225                break;
226            }
227            if delta > 0.0 {
228                t1 = t;
229            } else {
230                t0 = t;
231            }
232            t = 0.5 * (t0 + t1);
233        }
234    }
235
236    sample_curve(ay, by, cy, t)
237}
238
239/// Animation specification combining duration and easing.
240#[derive(Debug, Clone, Copy, PartialEq)]
241pub struct AnimationSpec {
242    /// Duration in milliseconds.
243    pub duration_millis: u64,
244    /// Easing function to apply.
245    pub easing: Easing,
246    /// Delay before starting animation in milliseconds.
247    pub delay_millis: u64,
248}
249
250impl AnimationSpec {
251    /// Create a tween animation with duration and easing.
252    pub fn tween(duration_millis: u64, easing: Easing) -> Self {
253        Self {
254            duration_millis,
255            easing,
256            delay_millis: 0,
257        }
258    }
259
260    /// Create a linear tween animation.
261    pub fn linear(duration_millis: u64) -> Self {
262        Self::tween(duration_millis, Easing::LinearEasing)
263    }
264
265    /// Add a delay before the animation starts.
266    pub fn with_delay(mut self, delay_millis: u64) -> Self {
267        self.delay_millis = delay_millis;
268        self
269    }
270}
271
272impl Default for AnimationSpec {
273    fn default() -> Self {
274        Self::tween(300, Easing::FastOutSlowInEasing)
275    }
276}
277
278/// Repeat mode for infinite animations.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum RepeatMode {
281    /// Restart from the beginning each cycle.
282    Restart,
283    /// Reverse direction every other cycle.
284    Reverse,
285}
286
287/// Start offset type for infinite animations.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum StartOffsetType {
290    /// Delay the start by the specified offset.
291    Delay,
292    /// Fast forward the start by the specified offset.
293    FastForward,
294}
295
296/// Start offset configuration for infinite animations.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub struct StartOffset {
299    /// Offset in milliseconds.
300    pub offset_millis: i64,
301    /// Offset behavior (delay or fast-forward).
302    pub offset_type: StartOffsetType,
303}
304
305impl Default for StartOffset {
306    fn default() -> Self {
307        Self {
308            offset_millis: 0,
309            offset_type: StartOffsetType::Delay,
310        }
311    }
312}
313
314/// Infinite repeatable animation spec built from a duration-based animation.
315#[derive(Debug, Clone, PartialEq)]
316pub struct InfiniteRepeatableSpec<T> {
317    /// Base animation used for each iteration.
318    pub animation: AnimationSpec,
319    /// Repeat mode (restart or reverse).
320    pub repeat_mode: RepeatMode,
321    /// Start offset applied before the first iteration.
322    pub initial_start_offset: StartOffset,
323    _marker: PhantomData<fn() -> T>,
324}
325
326/// Creates an infinite repeatable animation spec.
327pub fn infiniteRepeatable<T>(
328    animation: AnimationSpec,
329    repeat_mode: RepeatMode,
330    initial_start_offset: StartOffset,
331) -> InfiniteRepeatableSpec<T> {
332    InfiniteRepeatableSpec {
333        animation,
334        repeat_mode,
335        initial_start_offset,
336        _marker: PhantomData,
337    }
338}
339
340/// Spring animation configuration.
341#[derive(Debug, Clone, Copy, PartialEq)]
342pub struct SpringSpec {
343    /// Damping ratio. 1.0 = critically damped, < 1.0 = under-damped (bouncy), > 1.0 = over-damped.
344    pub damping_ratio: f32,
345    /// Stiffness constant. Higher values = faster animation.
346    pub stiffness: f32,
347    /// Velocity threshold to stop animation.
348    pub velocity_threshold: f32,
349    /// Position threshold to stop animation.
350    pub position_threshold: f32,
351    /// Delay before the spring begins advancing.
352    pub delay_millis: u64,
353}
354
355impl SpringSpec {
356    /// Create a spring with explicit Compose-style physics constants.
357    pub fn new(damping_ratio: f32, stiffness: f32) -> Self {
358        Self {
359            damping_ratio,
360            stiffness,
361            velocity_threshold: 0.1,
362            position_threshold: 0.01,
363            delay_millis: 0,
364        }
365    }
366
367    /// Add a delay before the spring starts integrating.
368    pub fn with_delay(mut self, delay_millis: u64) -> Self {
369        self.delay_millis = delay_millis;
370        self
371    }
372
373    /// Create a spring with default material design values.
374    pub fn default_spring() -> Self {
375        Self {
376            damping_ratio: 1.0,
377            stiffness: 1500.0,
378            velocity_threshold: 0.1,
379            position_threshold: 0.01,
380            delay_millis: 0,
381        }
382    }
383
384    /// Create a bouncy spring.
385    pub fn bouncy() -> Self {
386        Self {
387            damping_ratio: 0.5,
388            stiffness: 1500.0,
389            velocity_threshold: 0.1,
390            position_threshold: 0.01,
391            delay_millis: 0,
392        }
393    }
394
395    /// Create a stiff spring (fast, no bounce).
396    pub fn stiff() -> Self {
397        Self {
398            damping_ratio: 1.0,
399            stiffness: 3000.0,
400            velocity_threshold: 0.1,
401            position_threshold: 0.01,
402            delay_millis: 0,
403        }
404    }
405}
406
407impl Default for SpringSpec {
408    fn default() -> Self {
409        Self::default_spring()
410    }
411}
412
413/// Compose-compatible spring constants.
414pub struct Spring;
415
416impl Spring {
417    pub const DampingRatioNoBouncy: f32 = 1.0;
418    pub const DampingRatioLowBouncy: f32 = 0.75;
419    pub const DampingRatioMediumBouncy: f32 = 0.5;
420    pub const DampingRatioHighBouncy: f32 = 0.2;
421
422    pub const StiffnessHigh: f32 = 10_000.0;
423    pub const StiffnessMedium: f32 = 1_500.0;
424    pub const StiffnessMediumLow: f32 = 400.0;
425    pub const StiffnessLow: f32 = 200.0;
426    pub const StiffnessVeryLow: f32 = 50.0;
427}
428
429/// Compose-style spring animation spec factory.
430pub fn spring(damping_ratio: f32, stiffness: f32) -> AnimationType {
431    AnimationType::Spring(SpringSpec::new(damping_ratio, stiffness))
432}
433
434/// Compose-style tween animation spec factory.
435pub fn tween(duration_millis: u64, easing: Easing) -> AnimationType {
436    AnimationType::Tween(AnimationSpec::tween(duration_millis, easing))
437}
438
439/// Animation type specification.
440#[derive(Debug, Clone, Copy, PartialEq)]
441pub enum AnimationType {
442    /// Time-based tween animation.
443    Tween(AnimationSpec),
444    /// Physics-based spring animation.
445    Spring(SpringSpec),
446}
447
448impl AnimationType {
449    /// Add the same start delay regardless of the animation model.
450    pub fn with_delay(self, delay_millis: u64) -> Self {
451        match self {
452            Self::Tween(spec) => Self::Tween(spec.with_delay(delay_millis)),
453            Self::Spring(spec) => Self::Spring(spec.with_delay(delay_millis)),
454        }
455    }
456}
457
458impl Default for AnimationType {
459    fn default() -> Self {
460        AnimationType::Tween(AnimationSpec::default())
461    }
462}
463
464trait InfiniteTransitionAnimation {
465    fn on_frame(&self, play_time_nanos: u64);
466}
467
468struct TransitionAnimationState<T: Lerp + Clone + PartialEq + 'static> {
469    value_state: OwnedMutableState<T>,
470    initial_value: RefCell<T>,
471    target_value: RefCell<T>,
472    spec: RefCell<InfiniteRepeatableSpec<T>>,
473    start_on_next_frame: Cell<bool>,
474    play_time_offset_nanos: Cell<u64>,
475}
476
477impl<T: Lerp + Clone + PartialEq + 'static> TransitionAnimationState<T> {
478    fn new(
479        initial_value: T,
480        target_value: T,
481        spec: InfiniteRepeatableSpec<T>,
482        runtime: RuntimeHandle,
483    ) -> Self {
484        Self {
485            value_state: OwnedMutableState::with_runtime(initial_value.clone(), runtime),
486            initial_value: RefCell::new(initial_value),
487            target_value: RefCell::new(target_value),
488            spec: RefCell::new(spec),
489            start_on_next_frame: Cell::new(true),
490            play_time_offset_nanos: Cell::new(0),
491        }
492    }
493
494    fn state(&self) -> State<T> {
495        self.value_state.as_state()
496    }
497
498    fn update_values(&self, initial_value: T, target_value: T, spec: InfiniteRepeatableSpec<T>) {
499        let needs_update = {
500            let current_initial = self.initial_value.borrow();
501            let current_target = self.target_value.borrow();
502            *current_initial != initial_value
503                || *current_target != target_value
504                || *self.spec.borrow() != spec
505        };
506
507        if needs_update {
508            *self.initial_value.borrow_mut() = initial_value.clone();
509            *self.target_value.borrow_mut() = target_value;
510            *self.spec.borrow_mut() = spec;
511            self.start_on_next_frame.set(true);
512            self.value_state.set(initial_value);
513        }
514    }
515
516    fn compute_value(&self, play_time_nanos: u64) -> T {
517        let offset = if self.start_on_next_frame.get() {
518            self.start_on_next_frame.set(false);
519            self.play_time_offset_nanos.set(play_time_nanos);
520            play_time_nanos
521        } else {
522            self.play_time_offset_nanos.get()
523        };
524        let local_play_time = play_time_nanos.saturating_sub(offset);
525        let spec = self.spec.borrow().clone();
526        let initial = self.initial_value.borrow();
527        let target = self.target_value.borrow();
528        compute_repeatable_value(local_play_time, &initial, &target, spec)
529    }
530}
531
532impl<T: Lerp + Clone + PartialEq + 'static> InfiniteTransitionAnimation
533    for TransitionAnimationState<T>
534{
535    fn on_frame(&self, play_time_nanos: u64) {
536        let value = self.compute_value(play_time_nanos);
537        self.value_state.set(value);
538    }
539}
540
541fn compute_repeatable_value<T: Lerp + Clone>(
542    play_time_nanos: u64,
543    initial: &T,
544    target: &T,
545    spec: InfiniteRepeatableSpec<T>,
546) -> T {
547    let duration_ms = spec.animation.duration_millis.max(1) as i64;
548    let delay_ms = spec.animation.delay_millis as i64;
549    let mut play_time_ms = (play_time_nanos / 1_000_000) as i64;
550
551    match spec.initial_start_offset.offset_type {
552        StartOffsetType::Delay => {
553            play_time_ms -= spec.initial_start_offset.offset_millis;
554        }
555        StartOffsetType::FastForward => {
556            play_time_ms += spec.initial_start_offset.offset_millis;
557        }
558    }
559
560    if play_time_ms < 0 {
561        return initial.clone();
562    }
563
564    let iteration_duration = (delay_ms + duration_ms).max(1);
565    let iteration = play_time_ms / iteration_duration;
566    let iteration_time = play_time_ms % iteration_duration;
567
568    let reverse = matches!(spec.repeat_mode, RepeatMode::Reverse) && iteration % 2 != 0;
569    let (start, end) = if reverse {
570        (target, initial)
571    } else {
572        (initial, target)
573    };
574
575    if iteration_time < delay_ms {
576        return start.clone();
577    }
578
579    let linear_progress = ((iteration_time - delay_ms) as f32 / duration_ms as f32).clamp(0.0, 1.0);
580    let eased = spec.animation.easing.transform(linear_progress);
581    start.lerp(end, eased)
582}
583
584#[derive(Clone)]
585pub struct InfiniteTransition {
586    inner: Rc<InfiniteTransitionInner>,
587}
588
589struct InfiniteTransitionInner {
590    label: String,
591    animations: RefCell<Vec<Rc<dyn InfiniteTransitionAnimation>>>,
592    run_token: OwnedMutableState<u64>,
593}
594
595impl InfiniteTransition {
596    fn new(label: &str, runtime: RuntimeHandle) -> Self {
597        Self {
598            inner: Rc::new(InfiniteTransitionInner {
599                label: label.to_string(),
600                animations: RefCell::new(Vec::new()),
601                run_token: OwnedMutableState::with_runtime(0u64, runtime),
602            }),
603        }
604    }
605
606    pub fn label(&self) -> &str {
607        &self.inner.label
608    }
609
610    fn run(&self) {
611        let run_key = self.inner.run_token.get();
612        cranpose_core::label_next_ui_task(format!("loop {}", self.inner.label));
613        let weak: Weak<InfiniteTransitionInner> = Rc::downgrade(&self.inner);
614        cranpose_core::LaunchedEffectAsync!(run_key, move |scope| {
615            Box::pin(async move {
616                let clock = scope.runtime().frame_clock();
617                let mut start_time: Option<u64> = None;
618
619                loop {
620                    if !scope.is_active() {
621                        break;
622                    }
623
624                    let Some(inner) = weak.upgrade() else {
625                        break;
626                    };
627
628                    if inner.animations.borrow().is_empty() {
629                        break;
630                    }
631
632                    let now = clock.next_frame().await;
633                    if !scope.is_active() {
634                        break;
635                    }
636
637                    let start = start_time.get_or_insert(now);
638                    let play_time = now.saturating_sub(*start);
639                    inner.on_frame(play_time);
640                }
641            })
642        });
643    }
644
645    #[allow(non_snake_case)]
646    pub fn animateFloat(
647        &self,
648        initial_value: f32,
649        target_value: f32,
650        animation_spec: InfiniteRepeatableSpec<f32>,
651        label: &str,
652    ) -> State<f32> {
653        let _ = label;
654        self.animateValue(initial_value, target_value, animation_spec)
655    }
656
657    #[allow(non_snake_case)]
658    pub fn animateValue<T: Lerp + Clone + PartialEq + 'static>(
659        &self,
660        initial_value: T,
661        target_value: T,
662        animation_spec: InfiniteRepeatableSpec<T>,
663    ) -> State<T> {
664        let runtime = with_current_composer(|composer| composer.runtime_handle());
665        let initial_for_remember = initial_value.clone();
666        let target_for_remember = target_value.clone();
667        let spec_for_remember = animation_spec.clone();
668        let animation_state = cranpose_core::remember(move || {
669            Rc::new(TransitionAnimationState::new(
670                initial_for_remember,
671                target_for_remember,
672                spec_for_remember,
673                runtime.clone(),
674            ))
675        })
676        .with(Rc::clone);
677
678        let animation_state_for_effect = Rc::clone(&animation_state);
679        let spec_for_effect = animation_spec;
680        SideEffect(move || {
681            animation_state_for_effect.update_values(
682                initial_value.clone(),
683                target_value.clone(),
684                spec_for_effect,
685            );
686        });
687
688        let animation_any: Rc<dyn InfiniteTransitionAnimation> = animation_state.clone();
689        let transition_inner = Rc::clone(&self.inner);
690        let animation_id = Rc::as_ptr(&animation_state) as usize;
691        cranpose_core::DisposableEffect!(animation_id, move |_scope| {
692            transition_inner.add_animation(animation_any.clone());
693            let transition_inner = Rc::clone(&transition_inner);
694            let animation_any = animation_any.clone();
695            DisposableEffectResult::new(move || {
696                transition_inner.remove_animation(&animation_any);
697            })
698        });
699
700        animation_state.state()
701    }
702}
703
704impl InfiniteTransitionInner {
705    fn add_animation(&self, animation: Rc<dyn InfiniteTransitionAnimation>) {
706        let mut list = self.animations.borrow_mut();
707        let was_empty = list.is_empty();
708        let already_present = list.iter().any(|item| Rc::ptr_eq(item, &animation));
709        if !already_present {
710            list.push(animation);
711        }
712        if was_empty && !list.is_empty() {
713            self.run_token
714                .update(|value| *value = value.wrapping_add(1));
715        }
716    }
717
718    fn remove_animation(&self, animation: &Rc<dyn InfiniteTransitionAnimation>) {
719        let mut list = self.animations.borrow_mut();
720        let was_empty = list.is_empty();
721        if let Some(index) = list.iter().position(|item| Rc::ptr_eq(item, animation)) {
722            list.remove(index);
723        }
724        let is_empty = list.is_empty();
725        drop(list);
726
727        if !was_empty && is_empty {
728            self.run_token
729                .update(|value| *value = value.wrapping_add(1));
730        }
731    }
732
733    fn on_frame(&self, play_time_nanos: u64) {
734        let animations = self.animations.borrow().clone();
735        for animation in animations {
736            animation.on_frame(play_time_nanos);
737        }
738    }
739}
740
741#[allow(non_snake_case)]
742pub fn rememberInfiniteTransition(label: &str) -> InfiniteTransition {
743    let runtime = with_current_composer(|composer| composer.runtime_handle());
744    let transition =
745        cranpose_core::remember(move || InfiniteTransition::new(label, runtime.clone()))
746            .with(|transition| transition.clone());
747    transition.run();
748    transition
749}
750
751/// Generic animatable value holder.
752pub struct Animatable<T: SpringScalar + 'static> {
753    inner: Rc<RefCell<AnimatableInner<T>>>,
754}
755
756struct AnimatableInner<T: SpringScalar + 'static> {
757    state: OwnedMutableState<T>,
758    runtime: RuntimeHandle,
759    current: T,
760    /// Per-dimension velocity in value units per second. Preserved across
761    /// retargets so interrupted springs keep their physical motion.
762    velocity: [f32; SPRING_MAX_DIMENSIONS],
763    start: T,
764    target: T,
765    animation_type: AnimationType,
766    start_time_nanos: Option<u64>,
767    /// Previous spring frame timestamp; springs integrate the inter-frame
768    /// delta (tweens use `start_time_nanos` progress instead).
769    last_frame_nanos: Option<u64>,
770    registration: Option<FrameCallbackRegistration>,
771}
772
773impl<T: SpringScalar + 'static> Animatable<T> {
774    /// Create a new animatable with the given initial value.
775    pub fn new(initial: T, runtime: RuntimeHandle) -> Self {
776        let inner = AnimatableInner {
777            state: OwnedMutableState::with_runtime(initial.clone(), runtime.clone()),
778            runtime,
779            current: initial.clone(),
780            velocity: [0.0; SPRING_MAX_DIMENSIONS],
781            start: initial.clone(),
782            target: initial,
783            animation_type: AnimationType::default(),
784            start_time_nanos: None,
785            last_frame_nanos: None,
786            registration: None,
787        };
788        Self {
789            inner: Rc::new(RefCell::new(inner)),
790        }
791    }
792
793    /// Animate to the target value using the specified animation.
794    ///
795    /// Retargeting mid-flight keeps the in-flight velocity (springs continue
796    /// their physical motion toward the new target).
797    pub fn animateTo(&mut self, target: T, animation: AnimationType) {
798        self.start_animation(target, animation, None);
799    }
800
801    fn start_animation(
802        &mut self,
803        target: T,
804        animation: AnimationType,
805        exact_start_time_nanos: Option<u64>,
806    ) {
807        {
808            let mut inner = self.inner.borrow_mut();
809            let previous_animation = inner.animation_type;
810
811            // Cancel existing animation
812            if let Some(registration) = inner.registration.take() {
813                registration.cancel();
814            }
815
816            inner.start = inner.current.clone();
817            inner.target = target;
818            inner.animation_type = animation;
819            inner.start_time_nanos = exact_start_time_nanos;
820            match animation {
821                AnimationType::Spring(spec) => {
822                    let continues_spring = matches!(previous_animation, AnimationType::Spring(_));
823                    if let Some(start_time_nanos) = exact_start_time_nanos {
824                        let delay_nanos = spec.delay_millis.saturating_mul(1_000_000);
825                        inner.last_frame_nanos = Some(start_time_nanos.saturating_add(delay_nanos));
826                    } else if !continues_spring {
827                        inner.last_frame_nanos = None;
828                    }
829                }
830                AnimationType::Tween(_) => {
831                    inner.last_frame_nanos = None;
832                    inner.velocity = [0.0; SPRING_MAX_DIMENSIONS];
833                }
834            }
835            // The spring frame chain (`last_frame_nanos`) survives a
836            // retarget: a mid-flight spring keeps integrating real frame
837            // deltas toward the new target. Clearing it made the first
838            // frame after every retarget a dt=0 clock-set — under
839            // continuous per-move retargeting (gesture tracking) that
840            // starved the spring to a standstill. Tweens read only
841            // `start_time_nanos`, which does reset. A settled or fresh
842            // animatable enters with `last_frame_nanos == None` anyway.
843        }
844
845        Self::schedule_frame(&self.inner);
846    }
847
848    /// Animate to `target`, seeding the spring with `velocity` (value units
849    /// per second, per dimension) — the gesture-handoff entry point: pass the
850    /// release velocity so the animation continues the finger's motion.
851    pub fn animate_to_with_velocity(&mut self, target: T, velocity: T, animation: AnimationType) {
852        {
853            let mut inner = self.inner.borrow_mut();
854            for index in 0..T::DIMENSIONS.min(SPRING_MAX_DIMENSIONS) {
855                inner.velocity[index] = velocity.dimension(index);
856            }
857        }
858        self.animateTo(target, animation);
859    }
860
861    /// Animate from an exact point on the shared frame clock. The first
862    /// rendered spring sample integrates every elapsed nanosecond since this
863    /// boundary, so input-to-animation handoff is independent of which vsync
864    /// first services the callback.
865    pub fn animate_to_with_velocity_at(
866        &mut self,
867        target: T,
868        velocity: T,
869        animation: AnimationType,
870        start_time_nanos: u64,
871    ) {
872        {
873            let mut inner = self.inner.borrow_mut();
874            for index in 0..T::DIMENSIONS.min(SPRING_MAX_DIMENSIONS) {
875                inner.velocity[index] = velocity.dimension(index);
876            }
877        }
878        self.start_animation(target, animation, Some(start_time_nanos));
879    }
880
881    /// The current velocity in value units per second (zero when settled).
882    pub fn velocity(&self) -> T {
883        T::from_dimensions(self.inner.borrow().velocity)
884    }
885
886    /// Return the current animation target.
887    pub fn target(&self) -> T {
888        self.inner.borrow().target.clone()
889    }
890
891    /// Return the animation spec currently driving this animatable.
892    pub fn animation_type(&self) -> AnimationType {
893        self.inner.borrow().animation_type
894    }
895
896    /// Get the current state.
897    pub fn state(&self) -> State<T> {
898        self.inner.borrow().state.as_state()
899    }
900
901    /// Snap immediately to the target value without animating.
902    pub fn snapTo(&mut self, target: T) {
903        let mut inner = self.inner.borrow_mut();
904        if let Some(registration) = inner.registration.take() {
905            registration.cancel();
906        }
907        inner.current = target.clone();
908        inner.start = target.clone();
909        inner.target = target.clone();
910        inner.start_time_nanos = None;
911        inner.last_frame_nanos = None;
912        inner.velocity = [0.0; SPRING_MAX_DIMENSIONS];
913        inner.state.set_value(target);
914    }
915
916    fn schedule_frame(this: &Rc<RefCell<AnimatableInner<T>>>) {
917        let runtime = {
918            let inner = this.borrow();
919            if inner.registration.is_some() {
920                return;
921            }
922            inner.runtime.clone()
923        };
924        let weak = Rc::downgrade(this);
925        let registration = runtime.frame_clock().with_frame_nanos(move |time| {
926            if let Some(strong) = weak.upgrade() {
927                Self::on_frame(&strong, time);
928            }
929        });
930        this.borrow_mut().registration = Some(registration);
931    }
932
933    fn on_frame(this: &Rc<RefCell<AnimatableInner<T>>>, frame_time_nanos: u64) {
934        let mut schedule_next = false;
935        {
936            let mut inner = this.borrow_mut();
937            inner.registration = None;
938
939            match inner.animation_type {
940                AnimationType::Tween(spec) => {
941                    let start_time = inner.start_time_nanos.get_or_insert(frame_time_nanos);
942                    let elapsed_nanos = frame_time_nanos.saturating_sub(*start_time);
943                    let delay_nanos = spec.delay_millis.saturating_mul(1_000_000);
944
945                    if elapsed_nanos < delay_nanos {
946                        schedule_next = true;
947                    } else {
948                        let animation_elapsed = elapsed_nanos - delay_nanos;
949                        let duration_nanos = spec.duration_millis * 1_000_000;
950                        let duration_nanos = duration_nanos.max(1);
951                        let linear_progress =
952                            (animation_elapsed as f32 / duration_nanos as f32).clamp(0.0, 1.0);
953                        let progress = spec.easing.transform(linear_progress);
954
955                        let new_value = inner.start.lerp(&inner.target, progress);
956                        inner.current = new_value.clone();
957                        inner.state.set_value(new_value);
958
959                        if linear_progress >= 1.0 {
960                            inner.current = inner.target.clone();
961                            inner.start = inner.target.clone();
962                            inner.start_time_nanos = None;
963                            inner.state.set_value(inner.target.clone());
964                        } else {
965                            schedule_next = true;
966                        }
967                    }
968                }
969                AnimationType::Spring(spec) => {
970                    let start_time = inner.start_time_nanos.get_or_insert(frame_time_nanos);
971                    let elapsed_nanos = frame_time_nanos.saturating_sub(*start_time);
972                    let delay_nanos = spec.delay_millis.saturating_mul(1_000_000);
973                    if elapsed_nanos < delay_nanos {
974                        inner.last_frame_nanos = Some(start_time.saturating_add(delay_nanos));
975                        schedule_next = true;
976                    } else {
977                        // Damped harmonic oscillator advanced per dimension in
978                        // VALUE space using the closed-form solution (exact for
979                        // any frame delta — no integration drift at low frame
980                        // rates). Velocity carries across frames and retargets.
981                        let last = inner.last_frame_nanos.replace(frame_time_nanos);
982                        let dt = last
983                            .map(|last| {
984                                frame_time_nanos.saturating_sub(last) as f32 / 1_000_000_000.0
985                            })
986                            .unwrap_or(0.0);
987
988                        if dt <= 0.0 {
989                            schedule_next = true;
990                        } else {
991                            let dimensions = T::DIMENSIONS.min(SPRING_MAX_DIMENSIONS);
992                            let mut position = [0.0f32; SPRING_MAX_DIMENSIONS];
993                            for (index, slot) in position.iter_mut().enumerate().take(dimensions) {
994                                let value = inner.current.dimension(index);
995                                let target = inner.target.dimension(index);
996                                let (next_value, next_velocity) = advance_spring(
997                                    value,
998                                    inner.velocity[index],
999                                    target,
1000                                    spec.damping_ratio,
1001                                    spec.stiffness,
1002                                    dt,
1003                                );
1004                                *slot = next_value;
1005                                inner.velocity[index] = next_velocity;
1006                            }
1007
1008                            inner.current = T::from_dimensions(position);
1009                            inner.state.set_value(inner.current.clone());
1010
1011                            // Settled when every dimension is at rest near the target.
1012                            let settled = (0..dimensions).all(|index| {
1013                                inner.velocity[index].abs() < spec.velocity_threshold
1014                                    && (position[index] - inner.target.dimension(index)).abs()
1015                                        < spec.position_threshold
1016                            });
1017
1018                            if settled {
1019                                inner.current = inner.target.clone();
1020                                inner.start = inner.target.clone();
1021                                inner.start_time_nanos = None;
1022                                inner.last_frame_nanos = None;
1023                                inner.velocity = [0.0; SPRING_MAX_DIMENSIONS];
1024                                inner.state.set_value(inner.target.clone());
1025                            } else {
1026                                schedule_next = true;
1027                            }
1028                        }
1029                    }
1030                }
1031            }
1032        }
1033
1034        if schedule_next {
1035            Self::schedule_frame(this);
1036        }
1037    }
1038}
1039
1040/// [`animateFloatAsState`] with an explicit initial value: the first
1041/// composition seeds the animation at `initial` and animates toward `target`,
1042/// so newly appearing content can enter from 0 instead of snapping. The
1043/// building block for enter transitions (`Crossfade`, `AnimatedVisibility`,
1044/// morphing popups).
1045pub fn animate_float_as_state_with_initial(
1046    initial: f32,
1047    target: f32,
1048    animation: AnimationType,
1049    label: &str,
1050) -> State<f32> {
1051    let _ = label;
1052    with_current_composer(|composer| {
1053        let runtime = composer.runtime_handle();
1054        let anim: Owned<Animatable<f32>> = composer.remember(|| Animatable::new(initial, runtime));
1055        anim.update(|animatable| {
1056            let is_new_target = (animatable.target() - target).abs() > f32::EPSILON;
1057            let is_new_animation = animatable.animation_type() != animation;
1058            if is_new_target || is_new_animation {
1059                animatable.animateTo(target, animation);
1060            }
1061        });
1062        anim.with(|animatable| animatable.state())
1063    })
1064}
1065
1066#[allow(non_snake_case)]
1067pub fn animateFloatAsState(target: f32, animation: AnimationType, label: &str) -> State<f32> {
1068    let _ = label;
1069    with_current_composer(|composer| {
1070        let runtime = composer.runtime_handle();
1071        let anim: Owned<Animatable<f32>> = composer.remember(|| Animatable::new(target, runtime));
1072        anim.update(|animatable| {
1073            let is_new_target = (animatable.target() - target).abs() > f32::EPSILON;
1074            let is_new_animation = animatable.animation_type() != animation;
1075            if is_new_target || is_new_animation {
1076                animatable.animateTo(target, animation);
1077            }
1078        });
1079        anim.with(|animatable| animatable.state())
1080    })
1081}
1082
1083impl<T: SpringScalar + 'static> Clone for Animatable<T> {
1084    fn clone(&self) -> Self {
1085        Self {
1086            inner: self.inner.clone(),
1087        }
1088    }
1089}
1090
1091#[cfg(test)]
1092#[path = "tests/animation_tests.rs"]
1093mod tests;