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;