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