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;