Skip to main content

cranpose_animation/
animation.rs

1//! Animation system for Cranpose
2//!
3//! Provides time-based animations with easing curves and spring physics.
4//!
5//! Note: This module uses camelCase for method names (animateTo, snapTo) to maintain
6//! 1:1 API parity with Jetpack Compose.
7
8#![allow(non_snake_case)]
9#![allow(non_upper_case_globals)]
10
11use std::cell::{Cell, RefCell};
12use std::marker::PhantomData;
13use std::rc::{Rc, Weak};
14
15use cranpose_core::internal::FrameCallbackRegistration;
16use cranpose_core::{
17    with_current_composer, DisposableEffectResult, Owned, OwnedMutableState, RuntimeHandle,
18    SideEffect, State,
19};
20
21/// Trait for types that can be linearly interpolated.
22pub trait Lerp {
23    fn lerp(&self, target: &Self, fraction: f32) -> Self;
24}
25
26impl Lerp for f32 {
27    fn lerp(&self, target: &Self, fraction: f32) -> Self {
28        self + (target - self) * fraction
29    }
30}
31
32impl Lerp for f64 {
33    fn lerp(&self, target: &Self, fraction: f32) -> Self {
34        self + (target - self) * fraction as f64
35    }
36}
37
38/// Trait for values that can participate in spring animations.
39pub trait SpringScalar: Lerp + Clone {
40    /// Convert the value to `f32` for physics calculations.
41    fn to_f32(&self) -> f32;
42
43    /// Compute the current progress between the start and target values.
44    fn spring_progress(start: &Self, target: &Self, current: &Self) -> f32 {
45        let start_val = start.to_f32();
46        let target_val = target.to_f32();
47        let current_val = current.to_f32();
48
49        if (target_val - start_val).abs() < f32::EPSILON {
50            1.0
51        } else {
52            (current_val - start_val) / (target_val - start_val)
53        }
54    }
55
56    /// Determine whether the current value is close enough to the target to
57    /// consider the spring finished.
58    fn is_near_target(current: &Self, target: &Self, threshold: f32) -> bool {
59        (current.to_f32() - target.to_f32()).abs() < threshold
60    }
61}
62
63impl SpringScalar for f32 {
64    fn to_f32(&self) -> f32 {
65        *self
66    }
67}
68
69impl SpringScalar for f64 {
70    fn to_f32(&self) -> f32 {
71        *self as f32
72    }
73}
74
75/// Easing functions for animations matching Jetpack Compose.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub enum Easing {
78    /// Linear interpolation (no easing).
79    /// Jetpack Compose: LinearEasing
80    LinearEasing,
81    /// Ease in using cubic curve.
82    /// Jetpack Compose: EaseIn (not a standard constant, but supported)
83    EaseIn,
84    /// Ease out using cubic curve.
85    /// Jetpack Compose: EaseOut (not a standard constant, but supported)
86    EaseOut,
87    /// Ease in and out using cubic curve.
88    /// Jetpack Compose: EaseInOut (not a standard constant, but supported)
89    EaseInOut,
90    /// Fast out, slow in (material design standard).
91    /// Jetpack Compose: FastOutSlowInEasing
92    FastOutSlowInEasing,
93    /// Linear out, slow in (material design).
94    /// Jetpack Compose: LinearOutSlowInEasing
95    LinearOutSlowInEasing,
96    /// Fast out, linear in (material design).
97    /// Jetpack Compose: FastOutLinearEasing
98    FastOutLinearEasing,
99}
100
101impl Easing {
102    /// Apply the easing function to a linear fraction [0, 1].
103    pub fn transform(&self, fraction: f32) -> f32 {
104        match self {
105            Easing::LinearEasing => fraction,
106            Easing::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, fraction),
107            Easing::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, fraction),
108            Easing::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, fraction),
109            Easing::FastOutSlowInEasing => cubic_bezier(0.4, 0.0, 0.2, 1.0, fraction),
110            Easing::LinearOutSlowInEasing => cubic_bezier(0.0, 0.0, 0.2, 1.0, fraction),
111            Easing::FastOutLinearEasing => cubic_bezier(0.4, 0.0, 1.0, 1.0, fraction),
112        }
113    }
114}
115
116/// Cubic bezier curve approximation for easing.
117fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, fraction: f32) -> f32 {
118    if fraction <= 0.0 {
119        return 0.0;
120    }
121    if fraction >= 1.0 {
122        return 1.0;
123    }
124
125    let cx = 3.0 * x1;
126    let bx = 3.0 * (x2 - x1) - cx;
127    let ax = 1.0 - cx - bx;
128
129    let cy = 3.0 * y1;
130    let by = 3.0 * (y2 - y1) - cy;
131    let ay = 1.0 - cy - by;
132
133    fn sample_curve(a: f32, b: f32, c: f32, t: f32) -> f32 {
134        ((a * t + b) * t + c) * t
135    }
136
137    fn sample_derivative(a: f32, b: f32, c: f32, t: f32) -> f32 {
138        (3.0 * a * t + 2.0 * b) * t + c
139    }
140
141    // Use Newton-Raphson iterations to solve for the parametric value `t`
142    // corresponding to the provided x fraction. Clamp to [0, 1] to keep the
143    // solution within bounds.
144    let mut t = fraction;
145    let mut newton_success = false;
146    for _ in 0..8 {
147        let x = sample_curve(ax, bx, cx, t) - fraction;
148        if x.abs() < 1e-6 {
149            newton_success = true;
150            break;
151        }
152        let dx = sample_derivative(ax, bx, cx, t);
153        if dx.abs() < 1e-6 {
154            break;
155        }
156        t = (t - x / dx).clamp(0.0, 1.0);
157    }
158
159    if !newton_success {
160        // Fall back to a binary subdivision if Newton-Raphson did not converge.
161        let mut t0 = 0.0;
162        let mut t1 = 1.0;
163        t = fraction;
164        for _ in 0..16 {
165            let x = sample_curve(ax, bx, cx, t);
166            let delta = x - fraction;
167            if delta.abs() < 1e-6 {
168                break;
169            }
170            if delta > 0.0 {
171                t1 = t;
172            } else {
173                t0 = t;
174            }
175            t = 0.5 * (t0 + t1);
176        }
177    }
178
179    sample_curve(ay, by, cy, t)
180}
181
182/// Animation specification combining duration and easing.
183#[derive(Debug, Clone, Copy, PartialEq)]
184pub struct AnimationSpec {
185    /// Duration in milliseconds.
186    pub duration_millis: u64,
187    /// Easing function to apply.
188    pub easing: Easing,
189    /// Delay before starting animation in milliseconds.
190    pub delay_millis: u64,
191}
192
193impl AnimationSpec {
194    /// Create a tween animation with duration and easing.
195    pub fn tween(duration_millis: u64, easing: Easing) -> Self {
196        Self {
197            duration_millis,
198            easing,
199            delay_millis: 0,
200        }
201    }
202
203    /// Create a linear tween animation.
204    pub fn linear(duration_millis: u64) -> Self {
205        Self::tween(duration_millis, Easing::LinearEasing)
206    }
207
208    /// Add a delay before the animation starts.
209    pub fn with_delay(mut self, delay_millis: u64) -> Self {
210        self.delay_millis = delay_millis;
211        self
212    }
213}
214
215impl Default for AnimationSpec {
216    fn default() -> Self {
217        Self::tween(300, Easing::FastOutSlowInEasing)
218    }
219}
220
221/// Repeat mode for infinite animations.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum RepeatMode {
224    /// Restart from the beginning each cycle.
225    Restart,
226    /// Reverse direction every other cycle.
227    Reverse,
228}
229
230/// Start offset type for infinite animations.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum StartOffsetType {
233    /// Delay the start by the specified offset.
234    Delay,
235    /// Fast forward the start by the specified offset.
236    FastForward,
237}
238
239/// Start offset configuration for infinite animations.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct StartOffset {
242    /// Offset in milliseconds.
243    pub offset_millis: i64,
244    /// Offset behavior (delay or fast-forward).
245    pub offset_type: StartOffsetType,
246}
247
248impl Default for StartOffset {
249    fn default() -> Self {
250        Self {
251            offset_millis: 0,
252            offset_type: StartOffsetType::Delay,
253        }
254    }
255}
256
257/// Infinite repeatable animation spec built from a duration-based animation.
258#[derive(Debug, Clone, PartialEq)]
259pub struct InfiniteRepeatableSpec<T> {
260    /// Base animation used for each iteration.
261    pub animation: AnimationSpec,
262    /// Repeat mode (restart or reverse).
263    pub repeat_mode: RepeatMode,
264    /// Start offset applied before the first iteration.
265    pub initial_start_offset: StartOffset,
266    _marker: PhantomData<fn() -> T>,
267}
268
269/// Creates an infinite repeatable animation spec.
270pub fn infiniteRepeatable<T>(
271    animation: AnimationSpec,
272    repeat_mode: RepeatMode,
273    initial_start_offset: StartOffset,
274) -> InfiniteRepeatableSpec<T> {
275    InfiniteRepeatableSpec {
276        animation,
277        repeat_mode,
278        initial_start_offset,
279        _marker: PhantomData,
280    }
281}
282
283/// Spring animation configuration.
284#[derive(Debug, Clone, Copy, PartialEq)]
285pub struct SpringSpec {
286    /// Damping ratio. 1.0 = critically damped, < 1.0 = under-damped (bouncy), > 1.0 = over-damped.
287    pub damping_ratio: f32,
288    /// Stiffness constant. Higher values = faster animation.
289    pub stiffness: f32,
290    /// Velocity threshold to stop animation.
291    pub velocity_threshold: f32,
292    /// Position threshold to stop animation.
293    pub position_threshold: f32,
294}
295
296impl SpringSpec {
297    /// Create a spring with explicit Compose-style physics constants.
298    pub fn new(damping_ratio: f32, stiffness: f32) -> Self {
299        Self {
300            damping_ratio,
301            stiffness,
302            velocity_threshold: 0.01,
303            position_threshold: 0.001,
304        }
305    }
306
307    /// Create a spring with default material design values.
308    pub fn default_spring() -> Self {
309        Self {
310            damping_ratio: 1.0,
311            stiffness: 1500.0,
312            velocity_threshold: 0.01,
313            position_threshold: 0.001,
314        }
315    }
316
317    /// Create a bouncy spring.
318    pub fn bouncy() -> Self {
319        Self {
320            damping_ratio: 0.5,
321            stiffness: 1500.0,
322            velocity_threshold: 0.01,
323            position_threshold: 0.001,
324        }
325    }
326
327    /// Create a stiff spring (fast, no bounce).
328    pub fn stiff() -> Self {
329        Self {
330            damping_ratio: 1.0,
331            stiffness: 3000.0,
332            velocity_threshold: 0.01,
333            position_threshold: 0.001,
334        }
335    }
336}
337
338impl Default for SpringSpec {
339    fn default() -> Self {
340        Self::default_spring()
341    }
342}
343
344/// Compose-compatible spring constants.
345pub struct Spring;
346
347impl Spring {
348    pub const DampingRatioNoBouncy: f32 = 1.0;
349    pub const DampingRatioLowBouncy: f32 = 0.75;
350    pub const DampingRatioMediumBouncy: f32 = 0.5;
351    pub const DampingRatioHighBouncy: f32 = 0.2;
352
353    pub const StiffnessHigh: f32 = 10_000.0;
354    pub const StiffnessMedium: f32 = 1_500.0;
355    pub const StiffnessMediumLow: f32 = 400.0;
356    pub const StiffnessLow: f32 = 200.0;
357    pub const StiffnessVeryLow: f32 = 50.0;
358}
359
360/// Compose-style spring animation spec factory.
361pub fn spring(damping_ratio: f32, stiffness: f32) -> AnimationType {
362    AnimationType::Spring(SpringSpec::new(damping_ratio, stiffness))
363}
364
365/// Compose-style tween animation spec factory.
366pub fn tween(duration_millis: u64, easing: Easing) -> AnimationType {
367    AnimationType::Tween(AnimationSpec::tween(duration_millis, easing))
368}
369
370/// Animation type specification.
371#[derive(Debug, Clone, Copy, PartialEq)]
372pub enum AnimationType {
373    /// Time-based tween animation.
374    Tween(AnimationSpec),
375    /// Physics-based spring animation.
376    Spring(SpringSpec),
377}
378
379impl Default for AnimationType {
380    fn default() -> Self {
381        AnimationType::Tween(AnimationSpec::default())
382    }
383}
384
385trait InfiniteTransitionAnimation {
386    fn on_frame(&self, play_time_nanos: u64);
387}
388
389struct TransitionAnimationState<T: Lerp + Clone + PartialEq + 'static> {
390    value_state: OwnedMutableState<T>,
391    initial_value: RefCell<T>,
392    target_value: RefCell<T>,
393    spec: RefCell<InfiniteRepeatableSpec<T>>,
394    start_on_next_frame: Cell<bool>,
395    play_time_offset_nanos: Cell<u64>,
396}
397
398impl<T: Lerp + Clone + PartialEq + 'static> TransitionAnimationState<T> {
399    fn new(
400        initial_value: T,
401        target_value: T,
402        spec: InfiniteRepeatableSpec<T>,
403        runtime: RuntimeHandle,
404    ) -> Self {
405        Self {
406            value_state: OwnedMutableState::with_runtime(initial_value.clone(), runtime),
407            initial_value: RefCell::new(initial_value),
408            target_value: RefCell::new(target_value),
409            spec: RefCell::new(spec),
410            start_on_next_frame: Cell::new(true),
411            play_time_offset_nanos: Cell::new(0),
412        }
413    }
414
415    fn state(&self) -> State<T> {
416        self.value_state.as_state()
417    }
418
419    fn update_values(&self, initial_value: T, target_value: T, spec: InfiniteRepeatableSpec<T>) {
420        let needs_update = {
421            let current_initial = self.initial_value.borrow();
422            let current_target = self.target_value.borrow();
423            *current_initial != initial_value
424                || *current_target != target_value
425                || *self.spec.borrow() != spec
426        };
427
428        if needs_update {
429            *self.initial_value.borrow_mut() = initial_value.clone();
430            *self.target_value.borrow_mut() = target_value;
431            *self.spec.borrow_mut() = spec;
432            self.start_on_next_frame.set(true);
433            self.value_state.set(initial_value);
434        }
435    }
436
437    fn compute_value(&self, play_time_nanos: u64) -> T {
438        let offset = if self.start_on_next_frame.get() {
439            self.start_on_next_frame.set(false);
440            self.play_time_offset_nanos.set(play_time_nanos);
441            play_time_nanos
442        } else {
443            self.play_time_offset_nanos.get()
444        };
445        let local_play_time = play_time_nanos.saturating_sub(offset);
446        let spec = self.spec.borrow().clone();
447        let initial = self.initial_value.borrow();
448        let target = self.target_value.borrow();
449        compute_repeatable_value(local_play_time, &initial, &target, spec)
450    }
451}
452
453impl<T: Lerp + Clone + PartialEq + 'static> InfiniteTransitionAnimation
454    for TransitionAnimationState<T>
455{
456    fn on_frame(&self, play_time_nanos: u64) {
457        let value = self.compute_value(play_time_nanos);
458        self.value_state.set(value);
459    }
460}
461
462fn compute_repeatable_value<T: Lerp + Clone>(
463    play_time_nanos: u64,
464    initial: &T,
465    target: &T,
466    spec: InfiniteRepeatableSpec<T>,
467) -> T {
468    let duration_ms = spec.animation.duration_millis.max(1) as i64;
469    let delay_ms = spec.animation.delay_millis as i64;
470    let mut play_time_ms = (play_time_nanos / 1_000_000) as i64;
471
472    match spec.initial_start_offset.offset_type {
473        StartOffsetType::Delay => {
474            play_time_ms -= spec.initial_start_offset.offset_millis;
475        }
476        StartOffsetType::FastForward => {
477            play_time_ms += spec.initial_start_offset.offset_millis;
478        }
479    }
480
481    if play_time_ms < 0 {
482        return initial.clone();
483    }
484
485    let iteration_duration = (delay_ms + duration_ms).max(1);
486    let iteration = play_time_ms / iteration_duration;
487    let iteration_time = play_time_ms % iteration_duration;
488
489    let reverse = matches!(spec.repeat_mode, RepeatMode::Reverse) && iteration % 2 != 0;
490    let (start, end) = if reverse {
491        (target, initial)
492    } else {
493        (initial, target)
494    };
495
496    if iteration_time < delay_ms {
497        return start.clone();
498    }
499
500    let linear_progress = ((iteration_time - delay_ms) as f32 / duration_ms as f32).clamp(0.0, 1.0);
501    let eased = spec.animation.easing.transform(linear_progress);
502    start.lerp(end, eased)
503}
504
505#[derive(Clone)]
506pub struct InfiniteTransition {
507    inner: Rc<InfiniteTransitionInner>,
508}
509
510struct InfiniteTransitionInner {
511    label: String,
512    animations: RefCell<Vec<Rc<dyn InfiniteTransitionAnimation>>>,
513    run_token: OwnedMutableState<u64>,
514}
515
516impl InfiniteTransition {
517    fn new(label: &str, runtime: RuntimeHandle) -> Self {
518        Self {
519            inner: Rc::new(InfiniteTransitionInner {
520                label: label.to_string(),
521                animations: RefCell::new(Vec::new()),
522                run_token: OwnedMutableState::with_runtime(0u64, runtime),
523            }),
524        }
525    }
526
527    pub fn label(&self) -> &str {
528        &self.inner.label
529    }
530
531    fn run(&self) {
532        let run_key = self.inner.run_token.get();
533        let weak: Weak<InfiniteTransitionInner> = Rc::downgrade(&self.inner);
534        cranpose_core::LaunchedEffectAsync!(run_key, move |scope| {
535            Box::pin(async move {
536                let clock = scope.runtime().frame_clock();
537                let mut start_time: Option<u64> = None;
538
539                loop {
540                    if !scope.is_active() {
541                        break;
542                    }
543
544                    let Some(inner) = weak.upgrade() else {
545                        break;
546                    };
547
548                    if inner.animations.borrow().is_empty() {
549                        break;
550                    }
551
552                    let now = clock.next_frame().await;
553                    if !scope.is_active() {
554                        break;
555                    }
556
557                    let start = start_time.get_or_insert(now);
558                    let play_time = now.saturating_sub(*start);
559                    inner.on_frame(play_time);
560                }
561            })
562        });
563    }
564
565    #[allow(non_snake_case)]
566    pub fn animateFloat(
567        &self,
568        initial_value: f32,
569        target_value: f32,
570        animation_spec: InfiniteRepeatableSpec<f32>,
571        label: &str,
572    ) -> State<f32> {
573        let _ = label;
574        self.animateValue(initial_value, target_value, animation_spec)
575    }
576
577    #[allow(non_snake_case)]
578    pub fn animateValue<T: Lerp + Clone + PartialEq + 'static>(
579        &self,
580        initial_value: T,
581        target_value: T,
582        animation_spec: InfiniteRepeatableSpec<T>,
583    ) -> State<T> {
584        let runtime = with_current_composer(|composer| composer.runtime_handle());
585        let initial_for_remember = initial_value.clone();
586        let target_for_remember = target_value.clone();
587        let spec_for_remember = animation_spec.clone();
588        let animation_state = cranpose_core::remember(move || {
589            Rc::new(TransitionAnimationState::new(
590                initial_for_remember,
591                target_for_remember,
592                spec_for_remember,
593                runtime.clone(),
594            ))
595        })
596        .with(Rc::clone);
597
598        let animation_state_for_effect = Rc::clone(&animation_state);
599        let spec_for_effect = animation_spec;
600        SideEffect(move || {
601            animation_state_for_effect.update_values(
602                initial_value.clone(),
603                target_value.clone(),
604                spec_for_effect,
605            );
606        });
607
608        let animation_any: Rc<dyn InfiniteTransitionAnimation> = animation_state.clone();
609        let transition_inner = Rc::clone(&self.inner);
610        let animation_id = Rc::as_ptr(&animation_state) as usize;
611        cranpose_core::DisposableEffect!(animation_id, move |_scope| {
612            transition_inner.add_animation(animation_any.clone());
613            let transition_inner = Rc::clone(&transition_inner);
614            let animation_any = animation_any.clone();
615            DisposableEffectResult::new(move || {
616                transition_inner.remove_animation(&animation_any);
617            })
618        });
619
620        animation_state.state()
621    }
622}
623
624impl InfiniteTransitionInner {
625    fn add_animation(&self, animation: Rc<dyn InfiniteTransitionAnimation>) {
626        let mut list = self.animations.borrow_mut();
627        let was_empty = list.is_empty();
628        let already_present = list.iter().any(|item| Rc::ptr_eq(item, &animation));
629        if !already_present {
630            list.push(animation);
631        }
632        if was_empty && !list.is_empty() {
633            self.run_token
634                .update(|value| *value = value.wrapping_add(1));
635        }
636    }
637
638    fn remove_animation(&self, animation: &Rc<dyn InfiniteTransitionAnimation>) {
639        let mut list = self.animations.borrow_mut();
640        let was_empty = list.is_empty();
641        if let Some(index) = list.iter().position(|item| Rc::ptr_eq(item, animation)) {
642            list.remove(index);
643        }
644        let is_empty = list.is_empty();
645        drop(list);
646
647        if !was_empty && is_empty {
648            self.run_token
649                .update(|value| *value = value.wrapping_add(1));
650        }
651    }
652
653    fn on_frame(&self, play_time_nanos: u64) {
654        let animations = self.animations.borrow().clone();
655        for animation in animations {
656            animation.on_frame(play_time_nanos);
657        }
658    }
659}
660
661#[allow(non_snake_case)]
662pub fn rememberInfiniteTransition(label: &str) -> InfiniteTransition {
663    let runtime = with_current_composer(|composer| composer.runtime_handle());
664    let transition =
665        cranpose_core::remember(move || InfiniteTransition::new(label, runtime.clone()))
666            .with(|transition| transition.clone());
667    transition.run();
668    transition
669}
670
671/// Generic animatable value holder.
672pub struct Animatable<T: SpringScalar + 'static> {
673    inner: Rc<RefCell<AnimatableInner<T>>>,
674}
675
676struct AnimatableInner<T: SpringScalar + 'static> {
677    state: OwnedMutableState<T>,
678    runtime: RuntimeHandle,
679    current: T,
680    velocity: f32,
681    start: T,
682    target: T,
683    animation_type: AnimationType,
684    start_time_nanos: Option<u64>,
685    registration: Option<FrameCallbackRegistration>,
686}
687
688impl<T: SpringScalar + 'static> Animatable<T> {
689    /// Create a new animatable with the given initial value.
690    pub fn new(initial: T, runtime: RuntimeHandle) -> Self {
691        let inner = AnimatableInner {
692            state: OwnedMutableState::with_runtime(initial.clone(), runtime.clone()),
693            runtime,
694            current: initial.clone(),
695            velocity: 0.0,
696            start: initial.clone(),
697            target: initial,
698            animation_type: AnimationType::default(),
699            start_time_nanos: None,
700            registration: None,
701        };
702        Self {
703            inner: Rc::new(RefCell::new(inner)),
704        }
705    }
706
707    /// Animate to the target value using the specified animation.
708    pub fn animateTo(&mut self, target: T, animation: AnimationType) {
709        let should_schedule = {
710            let mut inner = self.inner.borrow_mut();
711
712            // Cancel existing animation
713            if let Some(registration) = inner.registration.take() {
714                registration.cancel();
715            }
716
717            inner.start = inner.current.clone();
718            inner.target = target;
719            inner.animation_type = animation;
720            inner.start_time_nanos = None;
721
722            true // Always schedule for now
723        };
724
725        if should_schedule {
726            Self::schedule_frame(&self.inner);
727        }
728    }
729
730    /// Return the current animation target.
731    pub fn target(&self) -> T {
732        self.inner.borrow().target.clone()
733    }
734
735    /// Return the animation spec currently driving this animatable.
736    pub fn animation_type(&self) -> AnimationType {
737        self.inner.borrow().animation_type
738    }
739
740    /// Get the current state.
741    pub fn state(&self) -> State<T> {
742        self.inner.borrow().state.as_state()
743    }
744
745    /// Snap immediately to the target value without animating.
746    pub fn snapTo(&mut self, target: T) {
747        let mut inner = self.inner.borrow_mut();
748        if let Some(registration) = inner.registration.take() {
749            registration.cancel();
750        }
751        inner.current = target.clone();
752        inner.start = target.clone();
753        inner.target = target.clone();
754        inner.start_time_nanos = None;
755        inner.state.set_value(target);
756    }
757
758    fn schedule_frame(this: &Rc<RefCell<AnimatableInner<T>>>) {
759        let runtime = {
760            let inner = this.borrow();
761            if inner.registration.is_some() {
762                return;
763            }
764            inner.runtime.clone()
765        };
766        let weak = Rc::downgrade(this);
767        let registration = runtime.frame_clock().with_frame_nanos(move |time| {
768            if let Some(strong) = weak.upgrade() {
769                Self::on_frame(&strong, time);
770            }
771        });
772        this.borrow_mut().registration = Some(registration);
773    }
774
775    fn on_frame(this: &Rc<RefCell<AnimatableInner<T>>>, frame_time_nanos: u64) {
776        let mut schedule_next = false;
777        {
778            let mut inner = this.borrow_mut();
779            inner.registration = None;
780
781            match inner.animation_type {
782                AnimationType::Tween(spec) => {
783                    let start_time = inner.start_time_nanos.get_or_insert(frame_time_nanos);
784                    let elapsed_nanos = frame_time_nanos.saturating_sub(*start_time);
785                    let delay_nanos = spec.delay_millis * 1_000_000;
786
787                    if elapsed_nanos < delay_nanos {
788                        schedule_next = true;
789                    } else {
790                        let animation_elapsed = elapsed_nanos - delay_nanos;
791                        let duration_nanos = spec.duration_millis * 1_000_000;
792                        let duration_nanos = duration_nanos.max(1);
793                        let linear_progress =
794                            (animation_elapsed as f32 / duration_nanos as f32).clamp(0.0, 1.0);
795                        let progress = spec.easing.transform(linear_progress);
796
797                        let new_value = inner.start.lerp(&inner.target, progress);
798                        inner.current = new_value.clone();
799                        inner.state.set_value(new_value);
800
801                        if linear_progress >= 1.0 {
802                            inner.current = inner.target.clone();
803                            inner.start = inner.target.clone();
804                            inner.start_time_nanos = None;
805                            inner.state.set_value(inner.target.clone());
806                        } else {
807                            schedule_next = true;
808                        }
809                    }
810                }
811                AnimationType::Spring(spec) => {
812                    // Implement spring physics using damped harmonic oscillator
813                    let start_time = inner.start_time_nanos.get_or_insert(frame_time_nanos);
814                    let elapsed_nanos = frame_time_nanos.saturating_sub(*start_time);
815                    let dt = elapsed_nanos as f32 / 1_000_000_000.0; // Convert to seconds
816
817                    // SpringScalar ensures we have scalar values that support the
818                    // physics calculations below (currently f32 and f64).
819                    if dt == 0.0 {
820                        schedule_next = true;
821                    } else {
822                        // Spring physics calculations
823                        // Using semi-implicit Euler integration for stability
824                        let stiffness = spec.stiffness;
825                        let damping = 2.0 * spec.damping_ratio * stiffness.sqrt();
826
827                        // Simulate spring from last frame to current frame
828                        let mut prev_time = 0.0f32;
829                        let timestep: f32 = 0.016; // ~60fps timestep for stability
830
831                        while prev_time < dt {
832                            let step = timestep.min(dt - prev_time);
833
834                            // Spring force: F = -k * displacement - damping * velocity
835                            // For interpolation between start and target:
836                            // We treat position as progress from 0 to 1
837                            let current_progress = <T as SpringScalar>::spring_progress(
838                                &inner.start,
839                                &inner.target,
840                                &inner.current,
841                            );
842
843                            let displacement = current_progress - 1.0; // Target is at 1.0
844                            let spring_force = -stiffness * displacement - damping * inner.velocity;
845
846                            // Update velocity and position
847                            inner.velocity += spring_force * step;
848                            let new_progress = current_progress + inner.velocity * step;
849
850                            // Update current value
851                            inner.current = inner
852                                .start
853                                .lerp(&inner.target, new_progress.clamp(0.0, 2.0));
854
855                            prev_time += step;
856                        }
857
858                        inner.state.set_value(inner.current.clone());
859
860                        // Check if we've settled (velocity and displacement both small)
861                        let at_rest = inner.velocity.abs() < spec.velocity_threshold;
862                        let near_target = <T as SpringScalar>::is_near_target(
863                            &inner.current,
864                            &inner.target,
865                            spec.position_threshold,
866                        );
867
868                        if at_rest && near_target {
869                            inner.current = inner.target.clone();
870                            inner.start = inner.target.clone();
871                            inner.start_time_nanos = None;
872                            inner.velocity = 0.0;
873                            inner.state.set_value(inner.target.clone());
874                        } else {
875                            schedule_next = true;
876                        }
877                    }
878                }
879            }
880        }
881
882        if schedule_next {
883            Self::schedule_frame(this);
884        }
885    }
886}
887
888#[allow(non_snake_case)]
889pub fn animateFloatAsState(target: f32, animation: AnimationType, label: &str) -> State<f32> {
890    let _ = label;
891    with_current_composer(|composer| {
892        let runtime = composer.runtime_handle();
893        let anim: Owned<Animatable<f32>> = composer.remember(|| Animatable::new(target, runtime));
894        anim.update(|animatable| {
895            let is_new_target = (animatable.target() - target).abs() > f32::EPSILON;
896            let is_new_animation = animatable.animation_type() != animation;
897            if is_new_target || is_new_animation {
898                animatable.animateTo(target, animation);
899            }
900        });
901        anim.with(|animatable| animatable.state())
902    })
903}
904
905impl<T: SpringScalar + 'static> Clone for Animatable<T> {
906    fn clone(&self) -> Self {
907        Self {
908            inner: self.inner.clone(),
909        }
910    }
911}
912
913#[cfg(test)]
914#[path = "tests/animation_tests.rs"]
915mod tests;