Skip to main content

cranpose_animation/
color.rs

1//! Color animation for Cranpose
2//!
3//! Mirrors Jetpack Compose's `animateColorAsState` from
4//! `androidx.compose.animation.SingleValueAnimation` by layering a [`Lerp`]
5//! implementation for [`Color`] over the generic [`Animatable`] machinery.
6//!
7//! Note: This module uses camelCase for function names to maintain 1:1 API
8//! parity with Jetpack Compose.
9
10#![allow(non_snake_case)]
11
12use crate::animation::{Animatable, AnimationType, Lerp, SpringScalar};
13use cranpose_core::{with_current_composer, Owned, State};
14use cranpose_ui_graphics::Color;
15
16impl Lerp for Color {
17    /// Linearly interpolate each RGBA channel, including alpha.
18    ///
19    /// Jetpack Compose's default `Color` lerp converts through the Oklab
20    /// color space before interpolating. Cranpose colors carry no color-space
21    /// information, so this implementation interpolates each channel linearly
22    /// in the color's own (linear RGBA) space instead. For typical UI fades
23    /// between colors in the same space this closely matches Compose's
24    /// behavior; the results are clamped to `[0.0, 1.0]` per channel so
25    /// overshooting springs still produce valid colors, matching Compose's
26    /// gamut coercion.
27    fn lerp(&self, target: &Self, fraction: f32) -> Self {
28        Color(
29            self.0.lerp(&target.0, fraction).clamp(0.0, 1.0),
30            self.1.lerp(&target.1, fraction).clamp(0.0, 1.0),
31            self.2.lerp(&target.2, fraction).clamp(0.0, 1.0),
32            self.3.lerp(&target.3, fraction).clamp(0.0, 1.0),
33        )
34    }
35}
36
37impl SpringScalar for Color {
38    /// Magnitude of the RGBA vector.
39    ///
40    /// Only a coarse scalar view: spring progress and settling for colors use
41    /// the 4-channel overrides below, mirroring how Compose animates colors
42    /// as `AnimationVector4D`.
43    fn to_f32(&self) -> f32 {
44        (self.0 * self.0 + self.1 * self.1 + self.2 * self.2 + self.3 * self.3).sqrt()
45    }
46
47    /// Progress of `current` along the 4D line from `start` to `target`,
48    /// computed as a vector projection so all channels contribute.
49    fn spring_progress(start: &Self, target: &Self, current: &Self) -> f32 {
50        let delta = [
51            target.0 - start.0,
52            target.1 - start.1,
53            target.2 - start.2,
54            target.3 - start.3,
55        ];
56        let len_sq: f32 = delta.iter().map(|d| d * d).sum();
57        if len_sq < f32::EPSILON {
58            1.0
59        } else {
60            let travelled = (current.0 - start.0) * delta[0]
61                + (current.1 - start.1) * delta[1]
62                + (current.2 - start.2) * delta[2]
63                + (current.3 - start.3) * delta[3];
64            travelled / len_sq
65        }
66    }
67
68    /// Euclidean distance across all four channels.
69    fn is_near_target(current: &Self, target: &Self, threshold: f32) -> bool {
70        let dr = current.0 - target.0;
71        let dg = current.1 - target.1;
72        let db = current.2 - target.2;
73        let da = current.3 - target.3;
74        (dr * dr + dg * dg + db * db + da * da).sqrt() < threshold
75    }
76}
77
78/// Fire-and-forget color animation. Returns a [`State`] whose value is
79/// updated by animations towards the provided `target` whenever `target`
80/// changes.
81///
82/// Mirrors Jetpack Compose:
83/// `animateColorAsState(targetValue, animationSpec, label)`.
84///
85/// The interpolation happens linearly per RGBA channel, including alpha (see
86/// [`Lerp`] for [`Color`] for how this relates to Compose's Oklab lerp).
87pub fn animateColorAsState(target: Color, animation: AnimationType, label: &str) -> State<Color> {
88    let _ = label;
89    with_current_composer(|composer| {
90        let runtime = composer.runtime_handle();
91        let anim: Owned<Animatable<Color>> = composer.remember(|| Animatable::new(target, runtime));
92        anim.update(|animatable| {
93            let is_new_target = animatable.target() != target;
94            let is_new_animation = animatable.animation_type() != animation;
95            if is_new_target || is_new_animation {
96                animatable.animateTo(target, animation);
97            }
98        });
99        anim.with(|animatable| animatable.state())
100    })
101}
102
103#[cfg(test)]
104#[path = "tests/color_tests.rs"]
105mod tests;