Skip to main content

waterui_core/animation_system/
animation.rs

1//! # `WaterUI` Animation System
2//!
3//! A reactive animation system that seamlessly integrates with `WaterUI`'s reactive state management.
4//!
5//! ## Overview
6//!
7//! The `WaterUI` animation system leverages the reactive framework to create smooth, declarative
8//! animations that automatically run when reactive values change. By attaching animation metadata
9//! to reactive values through convenient extension methods, the system can intelligently
10//! determine how to animate between different states without requiring explicit animation code.
11//!
12//! ```text
13//! ┌───────────────────┐      ┌───────────────────┐      ┌───────────────────┐
14//! │  Reactive Values  │─────>│ Change Propagation│─────>│  Animation System │
15//! │  (Binding/Compute)│      │ (With Animations) │      │  (Renderer)       │
16//! └───────────────────┘      └───────────────────┘      └───────────────────┘
17//! ```
18//!
19//! ## Core Concepts
20//!
21//! ### Animation Extension Methods
22//!
23//! `WaterUI` provides convenient extension methods on all reactive types to easily attach
24//! animation configurations:
25//!
26//! ```rust
27//! use waterui_core::{animation::Animation, AnimationExt, SignalExt};
28//! use nami::binding;
29//! use core::time::Duration;
30//!
31//! let opacity: nami::Binding<f32> = binding(1.0);
32//!
33//! // Use the .animated() method to apply the system-default animation
34//! let _animated_opacity = opacity.animated();
35//!
36//! // Or attach a specific animation with the generic metadata method
37//! let faded: nami::Binding<f32> = binding(0.0);
38//! let _custom_animated = faded.with(Animation::ease_in_out(Duration::from_millis(300)));
39//! ```
40//!
41//! The system supports two native primitives:
42//!
43//! - **`Bezier`**: Timed interpolation with cubic bezier control points
44//! - **`Spring`**: Physics-based animation with configurable stiffness and damping
45//!
46//! Convenience constructors (`linear`, `ease_in`, `ease_out`, `ease_in_out`) map to `Bezier`.
47//!
48//! ### Integration with UI Components
49//!
50//! UI components automatically respect animation metadata when rendering:
51//!
52//! ```rust
53//! use waterui_core::{animation::Animation, AnimationExt, SignalExt};
54//! use nami::binding;
55//! use core::time::Duration;
56//!
57//! let scale: nami::Binding<f32> = binding(1.0);
58//!
59//! // Three different ways to animate properties:
60//!
61//! // 1. Default animation (uses system defaults)
62//! let _view1_scale = scale.animated();
63//!
64//! // 2. Custom animation using a convenience constructor
65//! let expanded: nami::Binding<f32> = binding(2.0);
66//! let _view2_scale = expanded.with(Animation::ease_in_out(Duration::from_millis(300)));
67//!
68//! // 3. Spring animation using the convenience constructor
69//! let bouncing: nami::Binding<f32> = binding(0.5);
70//! let _view3_scale = bouncing.with(Animation::spring(100.0, 10.0));
71//! ```
72//!
73//! ## Animation Pipeline
74//!
75//! 1. **Reactive Setup**: Reactive values are wrapped with animation metadata using extension methods
76//! 2. **State Change**: When the underlying value changes, the animation information is preserved
77//! 3. **Propagation**: The change and animation details are propagated through the reactive system
78//! 4. **Value Interpolation**: The renderer calculates intermediate values based on animation type
79//! 5. **Rendering**: The UI is continuously updated with interpolated values until animation completes
80//!
81//! ## Advanced Features
82//!
83//! ### Animation Choreography
84//!
85//! Complex animations can be created by coordinating multiple animated values:
86//!
87//! ```rust
88//! use waterui_core::{animation::Animation, SignalExt};
89//! use nami::binding;
90//! use core::time::Duration;
91//!
92//! let opacity: nami::Binding<f32> = binding(0.0);
93//! let position: nami::Binding<(f32, f32)> = binding((0.0, 0.0));
94//!
95//! // Create a choreographed animation sequence
96//! let animated_opacity = opacity.with(Animation::ease_in_out(Duration::from_millis(300)));
97//!
98//! // Position animates with a spring physics model
99//! let animated_position = position.with(Animation::spring(100.0, 10.0));
100//!
101//! // Both animated values can be used in views
102//! // The UI framework will automatically handle the animation timing
103//! drop((animated_opacity, animated_position));
104//! ```
105//!
106//! ### Composition with Other Reactive Features
107//!
108//! Animation metadata seamlessly composes with other reactive features:
109//!
110//! ```rust
111//! use waterui_core::{animation::Animation, AnimationExt, SignalExt};
112//! use nami::binding;
113//! use core::time::Duration;
114//!
115//! let count: nami::Binding<i32> = binding(0i32);
116//! let value1: nami::Binding<i32> = binding(1i32);
117//! let value2: nami::Binding<i32> = binding(2i32);
118//!
119//! // Combine mapping and animation
120//! let opacity = count
121//!     .map(|n: i32| if n > 5 { 1.0 } else { 0.5 })
122//!     .animated();  // Apply animation to the mapped result
123//!
124//! // Combine multiple reactive values with animation
125//! let combined = value1
126//!     .zip(&value2)
127//!     .map(|(a, b)| a + b)
128//!     .with(Animation::ease_in_out(Duration::from_millis(250)));
129//!
130//! drop((opacity, combined)); // Prevent unused variable warnings
131//! ```
132//!
133
134use core::time::Duration;
135
136use crate::easing::{EasingCurve, Interpolatable};
137
138/// SwiftUI-style animation protocol.
139///
140/// Types expose an animatable representation (`AnimatableData`) that can be
141/// linearly interpolated by the animation system.
142pub trait Animatable: Clone {
143    /// Interpolatable payload used for frame-to-frame value blending.
144    type AnimatableData: Interpolatable;
145
146    /// Exports the value to its animatable representation.
147    fn animatable_data(&self) -> Self::AnimatableData;
148
149    /// Reconstructs the value from animatable data.
150    fn from_animatable_data(data: Self::AnimatableData) -> Self;
151}
152
153impl Animatable for f32 {
154    type AnimatableData = Self;
155
156    fn animatable_data(&self) -> Self::AnimatableData {
157        *self
158    }
159
160    fn from_animatable_data(data: Self::AnimatableData) -> Self {
161        data
162    }
163}
164
165impl Animatable for f64 {
166    type AnimatableData = Self;
167
168    fn animatable_data(&self) -> Self::AnimatableData {
169        *self
170    }
171
172    fn from_animatable_data(data: Self::AnimatableData) -> Self {
173        data
174    }
175}
176
177impl<A: Animatable, B: Animatable> Animatable for (A, B) {
178    type AnimatableData = (A::AnimatableData, B::AnimatableData);
179
180    fn animatable_data(&self) -> Self::AnimatableData {
181        (self.0.animatable_data(), self.1.animatable_data())
182    }
183
184    fn from_animatable_data(data: Self::AnimatableData) -> Self {
185        (
186            A::from_animatable_data(data.0),
187            B::from_animatable_data(data.1),
188        )
189    }
190}
191
192impl<A: Animatable, B: Animatable, C: Animatable> Animatable for (A, B, C) {
193    type AnimatableData = (A::AnimatableData, B::AnimatableData, C::AnimatableData);
194
195    fn animatable_data(&self) -> Self::AnimatableData {
196        (
197            self.0.animatable_data(),
198            self.1.animatable_data(),
199            self.2.animatable_data(),
200        )
201    }
202
203    fn from_animatable_data(data: Self::AnimatableData) -> Self {
204        (
205            A::from_animatable_data(data.0),
206            B::from_animatable_data(data.1),
207            C::from_animatable_data(data.2),
208        )
209    }
210}
211
212impl<A: Animatable, B: Animatable, C: Animatable, D: Animatable> Animatable for (A, B, C, D) {
213    type AnimatableData = (
214        A::AnimatableData,
215        B::AnimatableData,
216        C::AnimatableData,
217        D::AnimatableData,
218    );
219
220    fn animatable_data(&self) -> Self::AnimatableData {
221        (
222            self.0.animatable_data(),
223            self.1.animatable_data(),
224            self.2.animatable_data(),
225            self.3.animatable_data(),
226        )
227    }
228
229    fn from_animatable_data(data: Self::AnimatableData) -> Self {
230        (
231            A::from_animatable_data(data.0),
232            B::from_animatable_data(data.1),
233            C::from_animatable_data(data.2),
234            D::from_animatable_data(data.3),
235        )
236    }
237}
238
239impl<T: Animatable + Copy, const N: usize> Animatable for [T; N]
240where
241    T::AnimatableData: Copy,
242{
243    type AnimatableData = [T::AnimatableData; N];
244
245    fn animatable_data(&self) -> Self::AnimatableData {
246        core::array::from_fn(|index| self[index].animatable_data())
247    }
248
249    fn from_animatable_data(data: Self::AnimatableData) -> Self {
250        core::array::from_fn(|index| T::from_animatable_data(data[index]))
251    }
252}
253
254#[derive(Debug, Clone)]
255struct ActiveTrack<T: Animatable> {
256    animation: Animation,
257    elapsed: Duration,
258    from: T,
259    to: T,
260}
261
262/// Shared animation timeline state for values implementing [`Animatable`].
263#[derive(Debug, Clone)]
264pub struct AnimationTrack<T: Animatable> {
265    current: T,
266    active: Option<ActiveTrack<T>>,
267}
268
269impl<T: Animatable> AnimationTrack<T> {
270    /// Create a track seeded with an initial value.
271    #[must_use]
272    pub const fn new(initial: T) -> Self {
273        Self {
274            current: initial,
275            active: None,
276        }
277    }
278
279    /// Current sampled value.
280    #[must_use]
281    pub fn value(&self) -> T {
282        self.current.clone()
283    }
284
285    /// Replace the target value.
286    ///
287    /// If animation metadata is absent, the value is applied immediately.
288    pub fn set_target(&mut self, target: T, animation: Option<Animation>) {
289        let from = self.current.clone();
290        match animation {
291            Some(animation) if !animation.duration().is_zero() => {
292                self.active = Some(ActiveTrack {
293                    animation,
294                    elapsed: Duration::ZERO,
295                    from,
296                    to: target,
297                });
298            }
299            _ => {
300                self.current = target;
301                self.active = None;
302            }
303        }
304    }
305
306    /// Advance by a frame delta.
307    ///
308    /// Returns `true` while an animation remains active after advancement.
309    pub fn advance(&mut self, delta: Duration) -> bool {
310        let Some(active) = self.active.as_mut() else {
311            return false;
312        };
313
314        active.elapsed = active.elapsed.saturating_add(delta);
315        self.current = active
316            .animation
317            .interpolate(&active.from, &active.to, active.elapsed);
318
319        if active.animation.is_complete(active.elapsed) {
320            self.current = active.to.clone();
321            self.active = None;
322            false
323        } else {
324            true
325        }
326    }
327
328    /// Whether this track has an active animation.
329    #[must_use]
330    pub const fn is_active(&self) -> bool {
331        self.active.is_some()
332    }
333}
334
335/// Default duration for timed animations.
336const DEFAULT_TIMED_DURATION: Duration = Duration::from_millis(250);
337/// Default spring animation duration (used for timing calculations).
338const DEFAULT_SPRING_DURATION: Duration = Duration::from_millis(600);
339
340/// An enumeration representing different types of animations
341///
342/// This enum exposes two native animation primitives:
343/// - `Bezier`: Timed animations represented by cubic bezier control points
344/// - `Spring`: Physics-based movement with configurable stiffness and damping
345///
346/// Convenience constructors (`linear`, `ease_in`, etc.) all map to `Bezier`.
347#[derive(Debug, Default, Clone, PartialEq)]
348#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
349pub enum Animation {
350    /// Default animation behavior (uses system defaults)
351    #[default]
352    Default,
353    /// Timed cubic bezier animation with control points (x1, y1, x2, y2)
354    Bezier {
355        /// Animation duration
356        duration: Duration,
357        /// First control point X (0.0 to 1.0)
358        x1: f32,
359        /// First control point Y
360        y1: f32,
361        /// Second control point X (0.0 to 1.0)
362        x2: f32,
363        /// Second control point Y
364        y2: f32,
365    },
366    /// Spring animation with physics-based movement
367    Spring {
368        /// Stiffness of the spring (higher values create faster animations)
369        stiffness: f32,
370        /// Damping factor to control oscillation (higher values reduce bouncing)
371        damping: f32,
372    },
373}
374
375nami::impl_constant!(Animation);
376
377impl Animation {
378    /// Creates a new Linear animation with the specified duration
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use waterui_core::animation::Animation;
384    /// use core::time::Duration;
385    ///
386    /// let animation = Animation::linear(Duration::from_millis(300)); // 300ms
387    /// let animation = Animation::linear(Duration::from_secs(1)); // 1 second
388    /// ```
389    #[must_use]
390    pub const fn linear(duration: Duration) -> Self {
391        Self::Bezier {
392            duration,
393            x1: 0.0,
394            y1: 0.0,
395            x2: 1.0,
396            y2: 1.0,
397        }
398    }
399
400    /// Creates a new ease-in animation with the specified duration
401    ///
402    /// # Examples
403    ///
404    /// ```
405    /// use waterui_core::animation::Animation;
406    /// use core::time::Duration;
407    ///
408    /// let animation = Animation::ease_in(Duration::from_millis(300)); // 300ms
409    /// let animation = Animation::ease_in(Duration::from_secs(1)); // 1 second
410    /// ```
411    #[must_use]
412    pub const fn ease_in(duration: Duration) -> Self {
413        Self::Bezier {
414            duration,
415            x1: 0.42,
416            y1: 0.0,
417            x2: 1.0,
418            y2: 1.0,
419        }
420    }
421
422    /// Creates a new ease-out animation with the specified duration
423    ///
424    /// # Examples
425    ///
426    /// ```
427    /// use waterui_core::animation::Animation;
428    /// use core::time::Duration;
429    ///
430    /// let animation = Animation::ease_out(Duration::from_millis(300)); // 300ms
431    /// let animation = Animation::ease_out(Duration::from_secs(1)); // 1 second
432    /// ```
433    #[must_use]
434    pub const fn ease_out(duration: Duration) -> Self {
435        Self::Bezier {
436            duration,
437            x1: 0.0,
438            y1: 0.0,
439            x2: 0.58,
440            y2: 1.0,
441        }
442    }
443
444    /// Creates a new ease-in-out animation with the specified duration
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// use waterui_core::animation::Animation;
450    /// use core::time::Duration;
451    ///
452    /// let animation = Animation::ease_in_out(Duration::from_millis(300)); // 300ms
453    /// let animation = Animation::ease_in_out(Duration::from_secs(1)); // 1 second
454    /// ```
455    #[must_use]
456    pub const fn ease_in_out(duration: Duration) -> Self {
457        Self::Bezier {
458            duration,
459            x1: 0.42,
460            y1: 0.0,
461            x2: 0.58,
462            y2: 1.0,
463        }
464    }
465
466    /// Creates a new Spring animation with the specified stiffness and damping
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// use waterui_core::animation::Animation;
472    ///
473    /// let animation = Animation::spring(100.0, 10.0);
474    /// ```
475    ///
476    /// # Panics
477    ///
478    /// Panics if `stiffness` is not finite or is less than or equal to zero,
479    /// or if `damping` is not finite or is negative.
480    #[must_use]
481    pub const fn spring(stiffness: f32, damping: f32) -> Self {
482        assert!(
483            stiffness.is_finite() && stiffness > 0.0,
484            "Animation::spring requires finite stiffness > 0"
485        );
486        assert!(
487            damping.is_finite() && damping >= 0.0,
488            "Animation::spring requires finite damping >= 0"
489        );
490        Self::Spring { stiffness, damping }
491    }
492
493    /// Creates a new custom cubic bezier animation
494    ///
495    /// Control points define the shape of the easing curve.
496    /// Standard curves can be created with these control points:
497    /// - Linear: (0.0, 0.0, 1.0, 1.0)
498    /// - Ease-in: (0.42, 0.0, 1.0, 1.0)
499    /// - Ease-out: (0.0, 0.0, 0.58, 1.0)
500    /// - Ease-in-out: (0.42, 0.0, 0.58, 1.0)
501    ///
502    /// # Examples
503    ///
504    /// ```
505    /// use waterui_core::animation::Animation;
506    /// use core::time::Duration;
507    ///
508    /// // Custom bounce-like curve
509    /// let animation = Animation::bezier(Duration::from_millis(400), 0.25, 0.1, 0.25, 1.0);
510    /// ```
511    ///
512    /// # Panics
513    ///
514    /// Panics if any control point is non-finite or if `x1` or `x2` falls
515    /// outside the normalized `[0, 1]` range.
516    #[must_use]
517    pub const fn bezier(duration: Duration, x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
518        assert!(
519            !(!x1.is_finite() || !y1.is_finite() || !x2.is_finite() || !y2.is_finite()),
520            "Animation::bezier requires finite control points"
521        );
522        assert!(
523            !(x1 < 0.0 || x1 > 1.0 || x2 < 0.0 || x2 > 1.0),
524            "Animation::bezier requires x1/x2 in [0, 1]"
525        );
526        Self::Bezier {
527            duration,
528            x1,
529            y1,
530            x2,
531            y2,
532        }
533    }
534
535    /// Get the underlying easing curve for this animation.
536    ///
537    /// This allows using the unified easing system for interpolation.
538    #[must_use]
539    pub const fn curve(&self) -> EasingCurve {
540        match self {
541            Self::Default => EasingCurve::EASE_IN_OUT,
542            Self::Bezier { x1, y1, x2, y2, .. } => EasingCurve::bezier(*x1, *y1, *x2, *y2),
543            Self::Spring { stiffness, damping } => EasingCurve::spring(*stiffness, *damping),
544        }
545    }
546
547    /// Get the total duration of this animation.
548    ///
549    /// For spring animations, returns a default duration (600ms) since spring
550    /// duration depends on the physics parameters.
551    #[must_use]
552    pub const fn duration(&self) -> Duration {
553        match self {
554            Self::Default => DEFAULT_TIMED_DURATION,
555            Self::Bezier { duration: d, .. } => *d,
556            Self::Spring { .. } => DEFAULT_SPRING_DURATION,
557        }
558    }
559
560    /// Get the eased progress for the given elapsed time.
561    ///
562    /// Returns a value typically between 0.0 and 1.0, though spring animations
563    /// may temporarily overshoot (return values > 1.0 or < 0.0).
564    ///
565    /// # Arguments
566    ///
567    /// * `elapsed` - Time elapsed since animation started
568    #[must_use]
569    pub fn progress(&self, elapsed: Duration) -> f32 {
570        let duration = self.duration();
571        if duration.is_zero() {
572            return 1.0;
573        }
574
575        let t = (elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0);
576        self.curve().ease(t)
577    }
578
579    /// Interpolate between two values based on elapsed time.
580    ///
581    /// Uses the animation's easing curve to calculate the current value.
582    ///
583    /// # Examples
584    ///
585    /// ```
586    /// use waterui_core::animation::Animation;
587    /// use core::time::Duration;
588    ///
589    /// let anim = Animation::ease_in_out(Duration::from_millis(300));
590    /// let elapsed = Duration::from_millis(150); // halfway through
591    ///
592    /// let value = anim.interpolate(&0.0_f32, &100.0_f32, elapsed);
593    /// // value is approximately 50.0, but eased
594    /// ```
595    pub fn interpolate<T: Animatable>(&self, from: &T, to: &T, elapsed: Duration) -> T {
596        let progress = self.progress(elapsed);
597        let from_data = from.animatable_data();
598        let to_data = to.animatable_data();
599        let blended = from_data.lerp(&to_data, progress);
600        T::from_animatable_data(blended)
601    }
602
603    /// Returns true if the animation is complete.
604    ///
605    /// An animation is complete when the elapsed time equals or exceeds its duration.
606    #[must_use]
607    pub fn is_complete(&self, elapsed: Duration) -> bool {
608        elapsed >= self.duration()
609    }
610}
611
612use nami::signal::WithMetadata;
613
614/// Extension trait providing animation methods for reactive values.
615///
616/// This is the only animation extension trait in the framework: `waterui`
617/// re-exports it from its prelude, so `.animated()` means the same thing
618/// everywhere regardless of which import brought the trait into scope.
619pub trait AnimationExt: nami::SignalExt {
620    /// Attach the system-default animation to this reactive value.
621    ///
622    /// Equivalent to `self.with(Animation::Default)`; the backend picks the
623    /// curve and duration that match the platform. To pick an explicit
624    /// animation, use [`nami::SignalExt::with`] directly:
625    /// `value.with(Animation::spring(100.0, 10.0))`.
626    #[track_caller]
627    fn animated(&self) -> WithMetadata<Self, Animation> {
628        self.with(Animation::Default)
629    }
630}
631
632// Implement AnimationExt for all types that implement SignalExt
633impl<S: nami::SignalExt> AnimationExt for S {}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn convenience_curves_use_bezier_variant() {
641        assert!(matches!(
642            Animation::linear(Duration::from_millis(100)),
643            Animation::Bezier {
644                x1: 0.0,
645                y1: 0.0,
646                x2: 1.0,
647                y2: 1.0,
648                ..
649            }
650        ));
651        assert!(matches!(
652            Animation::ease_in(Duration::from_millis(100)),
653            Animation::Bezier {
654                x1: 0.42,
655                y1: 0.0,
656                x2: 1.0,
657                y2: 1.0,
658                ..
659            }
660        ));
661        assert!(matches!(
662            Animation::ease_out(Duration::from_millis(100)),
663            Animation::Bezier {
664                x1: 0.0,
665                y1: 0.0,
666                x2: 0.58,
667                y2: 1.0,
668                ..
669            }
670        ));
671        assert!(matches!(
672            Animation::ease_in_out(Duration::from_millis(100)),
673            Animation::Bezier {
674                x1: 0.42,
675                y1: 0.0,
676                x2: 0.58,
677                y2: 1.0,
678                ..
679            }
680        ));
681    }
682
683    #[test]
684    #[should_panic(expected = "stiffness > 0")]
685    fn spring_rejects_non_positive_stiffness() {
686        let _ = Animation::spring(0.0, 10.0);
687    }
688
689    #[test]
690    #[should_panic(expected = "damping >= 0")]
691    fn spring_rejects_negative_damping() {
692        let _ = Animation::spring(100.0, -1.0);
693    }
694
695    #[test]
696    #[should_panic(expected = "x1/x2 in [0, 1]")]
697    fn bezier_rejects_invalid_x_range() {
698        let _ = Animation::bezier(Duration::from_millis(100), -0.1, 0.0, 0.5, 1.0);
699    }
700
701    #[test]
702    fn animation_track_advances_to_target() {
703        let mut track = AnimationTrack::new(0.0_f32);
704        track.set_target(
705            1.0,
706            Some(Animation::ease_in_out(Duration::from_millis(120))),
707        );
708        assert!(track.advance(Duration::from_millis(60)));
709        let mid = track.value();
710        assert!(mid > 0.0 && mid < 1.0);
711        assert!(!track.advance(Duration::from_millis(120)));
712        assert!((track.value() - 1.0).abs() < 0.0001);
713    }
714
715    #[derive(Clone)]
716    struct Pair {
717        x: f32,
718        y: f32,
719    }
720
721    impl Animatable for Pair {
722        type AnimatableData = (f32, f32);
723
724        fn animatable_data(&self) -> Self::AnimatableData {
725            (self.x, self.y)
726        }
727
728        fn from_animatable_data(data: Self::AnimatableData) -> Self {
729            Self {
730                x: data.0,
731                y: data.1,
732            }
733        }
734    }
735
736    #[test]
737    fn custom_animatable_interpolates() {
738        let animation = Animation::linear(Duration::from_millis(100));
739        let from = Pair { x: 0.0, y: 0.0 };
740        let to = Pair { x: 10.0, y: 20.0 };
741        let value = animation.interpolate(&from, &to, Duration::from_millis(50));
742        assert!((value.x - 5.0).abs() < 0.001);
743        assert!((value.y - 10.0).abs() < 0.001);
744    }
745}