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
37/// Colors spring as four independent channels, mirroring how Compose animates
38/// `Color` as an `AnimationVector4D`. Channels are clamped back into `[0, 1]`
39/// when the vector is rebuilt, so overshooting springs still produce valid
40/// colors (Compose's gamut coercion).
41impl SpringScalar for Color {
42    const DIMENSIONS: usize = 4;
43
44    fn dimension(&self, index: usize) -> f32 {
45        match index {
46            0 => self.0,
47            1 => self.1,
48            2 => self.2,
49            _ => self.3,
50        }
51    }
52
53    fn from_dimensions(dimensions: [f32; crate::animation::SPRING_MAX_DIMENSIONS]) -> Self {
54        Color(
55            dimensions[0].clamp(0.0, 1.0),
56            dimensions[1].clamp(0.0, 1.0),
57            dimensions[2].clamp(0.0, 1.0),
58            dimensions[3].clamp(0.0, 1.0),
59        )
60    }
61}
62
63/// Fire-and-forget color animation. Returns a [`State`] whose value is
64/// updated by animations towards the provided `target` whenever `target`
65/// changes.
66///
67/// Mirrors Jetpack Compose:
68/// `animateColorAsState(targetValue, animationSpec, label)`.
69///
70/// The interpolation happens linearly per RGBA channel, including alpha (see
71/// [`Lerp`] for [`Color`] for how this relates to Compose's Oklab lerp).
72pub fn animateColorAsState(target: Color, animation: AnimationType, label: &str) -> State<Color> {
73    let _ = label;
74    with_current_composer(|composer| {
75        let runtime = composer.runtime_handle();
76        let anim: Owned<Animatable<Color>> = composer.remember(|| Animatable::new(target, runtime));
77        anim.update(|animatable| {
78            let is_new_target = animatable.target() != target;
79            let is_new_animation = animatable.animation_type() != animation;
80            if is_new_target || is_new_animation {
81                animatable.animateTo(target, animation);
82            }
83        });
84        anim.with(|animatable| animatable.state())
85    })
86}
87
88#[cfg(test)]
89#[path = "tests/color_tests.rs"]
90mod tests;