Skip to main content

cranpose_animation/
transition.rs

1//! Finite, state-driven, multi-property animation.
2//!
3//! Mirrors Jetpack Compose's `updateTransition`/`Transition<S>`: a
4//! [`Transition`] holds a target state, and each child animation (created
5//! with [`Transition::animateValue`] or one of the typed accessors) derives
6//! its own target value from that state and animates toward it in lockstep
7//! with its siblings. The transition is [`Transition::is_running`] only
8//! while at least one child is still mid-flight, so it settles exactly when
9//! every child has.
10//!
11//! Children reuse the same [`Animatable`]/[`SpringScalar`] machinery as
12//! [`animateFloatAsState`](crate::animateFloatAsState): each call site
13//! remembers its own `Animatable`, so retargeting mid-flight (the target
14//! state changing before the previous one finished) continues from the
15//! current value instead of snapping back to a start point, exactly like
16//! the single-value `animate*AsState` family already does.
17//!
18//! Note: This module uses camelCase for function/method names to maintain
19//! 1:1 API parity with Jetpack Compose.
20
21#![allow(non_snake_case)]
22
23use std::{cell::RefCell, rc::Rc};
24
25use cranpose_core::{DisposableEffectResult, Owned, State, with_current_composer};
26use cranpose_ui_graphics::{Color, Dp};
27
28use crate::animation::{Animatable, AnimationType, SpringScalar};
29
30trait TransitionChild {
31    fn is_running(&self) -> bool;
32}
33
34impl<T: SpringScalar + 'static> TransitionChild for Animatable<T> {
35    fn is_running(&self) -> bool {
36        Animatable::is_running(self)
37    }
38}
39
40struct TransitionInner<S> {
41    target_state: RefCell<S>,
42    children: RefCell<Vec<Rc<dyn TransitionChild>>>,
43}
44
45impl<S> TransitionInner<S> {
46    fn add_child(&self, child: Rc<dyn TransitionChild>) {
47        self.children.borrow_mut().push(child);
48    }
49
50    fn remove_child(&self, child: &Rc<dyn TransitionChild>) {
51        let mut children = self.children.borrow_mut();
52        if let Some(index) = children
53            .iter()
54            .position(|existing| Rc::ptr_eq(existing, child))
55        {
56            children.remove(index);
57        }
58    }
59
60    fn is_running(&self) -> bool {
61        self.children
62            .borrow()
63            .iter()
64            .any(|child| child.is_running())
65    }
66}
67
68/// A finite, multi-property animation driven by a target state `S`.
69///
70/// Obtained from [`updateTransition`]; see the module docs for the overall
71/// model.
72pub struct Transition<S: 'static> {
73    inner: Rc<TransitionInner<S>>,
74}
75
76impl<S> Clone for Transition<S> {
77    fn clone(&self) -> Self {
78        Self {
79            inner: Rc::clone(&self.inner),
80        }
81    }
82}
83
84impl<S: Clone + 'static> Transition<S> {
85    fn new(target_state: S) -> Self {
86        Self {
87            inner: Rc::new(TransitionInner {
88                target_state: RefCell::new(target_state),
89                children: RefCell::new(Vec::new()),
90            }),
91        }
92    }
93
94    fn set_target_state(&self, target_state: S) {
95        *self.inner.target_state.borrow_mut() = target_state;
96    }
97
98    /// The state this transition is currently animating towards.
99    pub fn target_state(&self) -> S {
100        self.inner.target_state.borrow().clone()
101    }
102
103    /// `true` while any child animation is still mid-flight. A transition
104    /// with no children yet, or whose children have all settled, reports
105    /// `false` -- it is finished only when every child is.
106    pub fn is_running(&self) -> bool {
107        self.inner.is_running()
108    }
109
110    /// Generic child animation, built on the same [`SpringScalar`]
111    /// vector-converter core as [`crate::animateValueAsState`]. `target_value`
112    /// is the value this child should hold once the transition settles for
113    /// the current [`Transition::target_state`] -- recomputed by the caller
114    /// on every call, exactly like the standalone `animate*AsState` family.
115    #[track_caller]
116    pub fn animateValue<T: SpringScalar + PartialEq + 'static>(
117        &self,
118        target_value: T,
119        animation: AnimationType,
120        label: &str,
121    ) -> State<T> {
122        let _ = label;
123        let caller = cranpose_core::caller_location_key();
124        with_current_composer(|composer| {
125            let runtime = composer.runtime_handle();
126            let anim: Owned<Animatable<T>> = composer.remember_at(caller, || {
127                Animatable::new_with_animation(target_value.clone(), animation, runtime)
128            });
129            anim.update(|animatable| {
130                let is_new_target = animatable.target() != target_value;
131                let is_new_animation = animatable.animation_type() != animation;
132                if is_new_target || is_new_animation {
133                    animatable.animateTo(target_value.clone(), animation);
134                }
135            });
136
137            let animatable_clone = anim.with(|animatable| animatable.clone());
138            let identity = animatable_clone.identity();
139            let transition_inner = Rc::clone(&self.inner);
140            cranpose_core::__disposable_effect_impl(
141                caller ^ cranpose_core::location_key(file!(), line!(), column!()),
142                identity,
143                move |_scope| {
144                    let child: Rc<dyn TransitionChild> = Rc::new(animatable_clone);
145                    transition_inner.add_child(Rc::clone(&child));
146                    let transition_inner = Rc::clone(&transition_inner);
147                    DisposableEffectResult::new(move || {
148                        transition_inner.remove_child(&child);
149                    })
150                },
151            );
152
153            anim.with(|animatable| animatable.state())
154        })
155    }
156
157    /// Child float animation. Mirrors Jetpack Compose's `Transition.animateFloat`.
158    #[track_caller]
159    pub fn animateFloat(
160        &self,
161        target_value: f32,
162        animation: AnimationType,
163        label: &str,
164    ) -> State<f32> {
165        self.animateValue(target_value, animation, label)
166    }
167
168    /// Child density-independent-length animation. Mirrors Jetpack Compose's
169    /// `Transition.animateDp`.
170    #[track_caller]
171    pub fn animateDp(&self, target_value: Dp, animation: AnimationType, label: &str) -> State<Dp> {
172        self.animateValue(target_value, animation, label)
173    }
174
175    /// Child color animation. Mirrors Jetpack Compose's `Transition.animateColor`.
176    #[track_caller]
177    pub fn animateColor(
178        &self,
179        target_value: Color,
180        animation: AnimationType,
181        label: &str,
182    ) -> State<Color> {
183        self.animateValue(target_value, animation, label)
184    }
185}
186
187/// Creates or updates a [`Transition`] targeting `target_state`. Every child
188/// animation added with [`Transition::animateValue`] (or a typed accessor)
189/// retargets when `target_state` changes, continuing from its current value
190/// rather than snapping.
191///
192/// Mirrors Jetpack Compose: `updateTransition(targetState, label)`.
193#[track_caller]
194pub fn updateTransition<S: Clone + 'static>(target_state: S, label: &str) -> Transition<S> {
195    let _ = label;
196    let caller = cranpose_core::caller_location_key();
197    with_current_composer(|composer| {
198        let transition: Owned<Transition<S>> =
199            composer.remember_at(caller, || Transition::new(target_state.clone()));
200        transition.with(|transition| transition.set_target_state(target_state.clone()));
201        transition.with(|transition| transition.clone())
202    })
203}
204
205#[cfg(test)]
206#[path = "tests/transition_tests.rs"]
207mod tests;