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