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}