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 cranpose_core::{Owned, State, with_current_composer};
13use cranpose_ui_graphics::Color;
14
15use crate::animation::{Animatable, AnimationType, Lerp, SpringScalar};
16
17impl Lerp for Color {
18    /// Linearly interpolate each RGBA channel, including alpha.
19    ///
20    /// Jetpack Compose's default `Color` lerp converts through the Oklab
21    /// color space before interpolating. Cranpose colors carry no color-space
22    /// information, so this implementation interpolates each channel linearly
23    /// in the color's own (linear RGBA) space instead. For typical UI fades
24    /// between colors in the same space this closely matches Compose's
25    /// behavior; the results are clamped to `[0.0, 1.0]` per channel so
26    /// overshooting springs still produce valid colors, matching Compose's
27    /// gamut coercion.
28    fn lerp(&self, target: &Self, fraction: f32) -> Self {
29        Color(
30            self.0.lerp(&target.0, fraction).clamp(0.0, 1.0),
31            self.1.lerp(&target.1, fraction).clamp(0.0, 1.0),
32            self.2.lerp(&target.2, fraction).clamp(0.0, 1.0),
33            self.3.lerp(&target.3, fraction).clamp(0.0, 1.0),
34        )
35    }
36}
37
38/// Colors spring as four independent channels, mirroring how Compose animates
39/// `Color` as an `AnimationVector4D`. Channels are clamped back into `[0, 1]`
40/// when the vector is rebuilt, so overshooting springs still produce valid
41/// colors (Compose's gamut coercion).
42impl SpringScalar for Color {
43    const DIMENSIONS: usize = 4;
44
45    fn dimension(&self, index: usize) -> f32 {
46        match index {
47            0 => self.0,
48            1 => self.1,
49            2 => self.2,
50            _ => self.3,
51        }
52    }
53
54    fn from_dimensions(dimensions: [f32; crate::animation::SPRING_MAX_DIMENSIONS]) -> Self {
55        Color(
56            dimensions[0].clamp(0.0, 1.0),
57            dimensions[1].clamp(0.0, 1.0),
58            dimensions[2].clamp(0.0, 1.0),
59            dimensions[3].clamp(0.0, 1.0),
60        )
61    }
62}
63
64/// Fire-and-forget color animation. Returns a [`State`] whose value is
65/// updated by animations towards the provided `target` whenever `target`
66/// changes.
67///
68/// Mirrors Jetpack Compose:
69/// `animateColorAsState(targetValue, animationSpec, label)`.
70///
71/// The interpolation happens linearly per RGBA channel, including alpha (see
72/// [`Lerp`] for [`Color`] for how this relates to Compose's Oklab lerp).
73#[track_caller]
74pub fn animateColorAsState(target: Color, animation: AnimationType, label: &str) -> State<Color> {
75    let _ = label;
76    let caller = cranpose_core::caller_location_key();
77    with_current_composer(|composer| {
78        let runtime = composer.runtime_handle();
79        let anim: Owned<Animatable<Color>> =
80            composer.remember_at(caller, || Animatable::new(target, runtime));
81        anim.update(|animatable| {
82            let is_new_target = animatable.target() != target;
83            let is_new_animation = animatable.animation_type() != animation;
84            if is_new_target || is_new_animation {
85                animatable.animateTo(target, animation);
86            }
87        });
88        anim.with(|animatable| animatable.state())
89    })
90}
91
92#[cfg(test)]
93#[path = "tests/color_tests.rs"]
94mod tests;