diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fb8e64..c09c0860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ that do not use them. ### Added +- Added `AnimatedOpacity` and `AnimatedContainer` implicit target-value widgets, + a public user-implementable `Curve` trait, nine named curve presets, and a + twenty-example Animation Laboratory covering keyframes, orchestration, + physics, composition, interruption and reduced motion. - Added incremental `TextEdit` delivery for `Input` and `TextArea`, including `on_edit`, context callback helpers, UTF-8 validation, fragment-based history, and lazy compatibility delivery for the existing `on_input(String)` API. diff --git a/README.md b/README.md index 156cf80b..6237454b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,8 @@ Available widget flags are `widget-accordion`, `widget-alert`, `widget-command-palette`, `widget-context-menu`, `widget-data-table`, `widget-date-picker`, `widget-dialog`, `widget-direction`, `widget-drawer`, `widget-empty`, `widget-field`, `widget-file-picker`, `widget-hover-card`, -`widget-icons`, `widget-input`, `widget-input-group`, `widget-input-otp`, +`widget-icons`, `widget-implicit-animation`, `widget-input`, `widget-input-group`, +`widget-input-otp`, `widget-item`, `widget-kbd`, `widget-label`, `widget-list`, `widget-marker`, `widget-menu`, `widget-menubar`, `widget-message`, `widget-message-scroller`, `widget-native-select`, `widget-navigation-menu`, `widget-pagination`, diff --git a/app_examples/docs-examples/Cargo.toml b/app_examples/docs-examples/Cargo.toml index ae546eb5..14fe4f30 100644 --- a/app_examples/docs-examples/Cargo.toml +++ b/app_examples/docs-examples/Cargo.toml @@ -17,6 +17,7 @@ argui = { workspace = true, features = [ "tasks", "widget-button", "widget-dialog", + "widget-implicit-animation", "widget-popover", "widget-range", "widget-textarea", diff --git a/app_examples/docs-examples/src/examples/animation.rs b/app_examples/docs-examples/src/examples/animation.rs index c7dbdafa..9355e08e 100644 --- a/app_examples/docs-examples/src/examples/animation.rs +++ b/app_examples/docs-examples/src/examples/animation.rs @@ -1,4 +1,5 @@ use argui::{ + animation::{Duration, curves}, core::{ColorInterpolation, Transform2D, TransformOrigin}, paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId}, runtime::{Context, LayoutSnapshot, Render}, @@ -8,7 +9,7 @@ use argui::{ percent, }, vector::VectorLibrary, - widgets::{Button, WidgetTheme, default_theme}, + widgets::{AnimatedContainer, AnimatedOpacity, Button, WidgetTheme, default_theme}, }; const ORBIT_SVG: &[u8] = br#" @@ -24,6 +25,7 @@ pub struct Example { running: bool, reduced_motion: bool, compact: bool, + expanded: bool, vectors: VectorLibrary, orbit: VectorId, spark: VectorId, @@ -39,6 +41,7 @@ impl Default for Example { running: true, reduced_motion: false, compact: false, + expanded: true, vectors, orbit, spark, @@ -221,7 +224,40 @@ impl Example { .radius(CornerRadii::all(999.0)) .transform(Transform2D::IDENTITY.scale(pulse, pulse)); + let implicit_opacity = AnimatedOpacity::new( + "docs-implicit-opacity", + if self.expanded { 1.0 } else { 0.12 }, + self.text("Target-driven fade", 17.0, 700, theme.primary), + ) + .duration(Duration::from_millis(260)) + .curve(curves::EASE_OUT) + .build(); + let implicit_container = AnimatedContainer::new("docs-implicit-container", []) + .width(length(if self.expanded { 188.0 } else { 72.0 })) + .height(length(if self.expanded { 76.0 } else { 46.0 })) + .background(if self.expanded { + theme.destructive + } else { + theme.primary + }) + .radius(CornerRadii::all(if self.expanded { 30.0 } else { 10.0 })) + .duration(Duration::from_millis(420)) + .curve(curves::EMPHASIZED) + .build(); + Element::row([ + self.card( + "AnimatedOpacity", + "A rebuild changes only the target; retained state owns the fade.", + implicit_opacity, + theme, + ), + self.card( + "AnimatedContainer", + "Size, color and radius transition with one declarative policy.", + implicit_container, + theme, + ), self.card( "Composed transform", "Translate, rotate, scale, and radius run in one two-second loop.", @@ -291,7 +327,7 @@ impl Render for Example { let status = if self.reduced_motion { "Reduced motion is active" } else if self.running { - "Six loops · 2 seconds" + "Six loops · two implicit examples" } else { "Animations paused" }; @@ -314,6 +350,14 @@ impl Render for Example { .enabled(!self.reduced_motion) .on_click(cx.callback(|app| app.running = !app.running)) .build(), + Button::new( + "toggle-implicit", + "Retarget implicit examples", + theme.outline_button(), + ) + .enabled(!self.reduced_motion) + .on_click(cx.callback(|app| app.expanded = !app.expanded)) + .build(), ]) .width(percent(1.0)) .padding(Sides::length(14.0)) diff --git a/app_examples/docs-examples/tests/behavior.rs b/app_examples/docs-examples/tests/behavior.rs index cf306fc7..6437b9d7 100644 --- a/app_examples/docs-examples/tests/behavior.rs +++ b/app_examples/docs-examples/tests/behavior.rs @@ -22,6 +22,8 @@ fn accessibility_example_announces_each_activation() { #[test] fn animation_example_toggles_locally() { let mut app = TestApp::new(examples::animation::Example::default()); + app.click("toggle-implicit").unwrap(); + app.assert_text("Retarget implicit examples"); app.click("toggle-animation").unwrap(); app.assert_text("Animations paused"); app.assert_text("Resume animations"); diff --git a/crates/argui-animation/src/controller.rs b/crates/argui-animation/src/controller.rs index 4be53994..3289b475 100644 --- a/crates/argui-animation/src/controller.rs +++ b/crates/argui-animation/src/controller.rs @@ -1,5 +1,5 @@ use crate::{ - Composition, Duration, Easing, FillMode, Interpolate, Keyframe, Keyframes, MotionValue, + Composition, Curve, Duration, Easing, FillMode, Interpolate, Keyframe, Keyframes, MotionValue, PlaybackState, Spring, SpringConfig, Time, Timeline, Timing, TimingError, }; use std::{ @@ -43,6 +43,13 @@ impl Tween { self.easing = easing; self } + + /// Sets a built-in or user-defined curve for this tween. + #[must_use] + pub fn curve(mut self, curve: impl Curve) -> Self { + self.easing = Easing::curve(curve); + self + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] diff --git a/crates/argui-animation/src/easing.rs b/crates/argui-animation/src/easing.rs index c279e3b1..29c1690f 100644 --- a/crates/argui-animation/src/easing.rs +++ b/crates/argui-animation/src/easing.rs @@ -2,6 +2,27 @@ use std::{fmt, sync::Arc}; type CustomFunction = dyn Fn(f32) -> f32 + Send + Sync + 'static; +/// Maps normalized time to animation progress. +/// +/// Argui clamps the input supplied through [`Easing::curve`] to `0.0..=1.0`. +/// Implementations may return values outside that interval to create anticipation +/// or overshoot. Curves should return finite values and preserve the endpoints +/// when they are intended to finish exactly on their animated target. +pub trait Curve: Send + Sync + 'static { + /// Samples the curve at normalized `progress` and returns transformed progress. + #[must_use] + fn sample(&self, progress: f32) -> f32; +} + +impl Curve for F +where + F: Fn(f32) -> f32 + Send + Sync + 'static, +{ + fn sample(&self, progress: f32) -> f32 { + self(progress) + } +} + #[derive(Clone, Default)] /// Maps normalized animation progress to eased progress. pub enum Easing { @@ -25,6 +46,12 @@ impl Easing { Self::Custom(Arc::new(function)) } + /// Wraps a user-defined [`Curve`] as an easing value. + #[must_use] + pub fn curve(curve: impl Curve) -> Self { + Self::Custom(Arc::new(move |progress| curve.sample(progress))) + } + /// Creates piecewise-linear easing from sorted stops spanning input zero to one. /// /// # Errors @@ -61,6 +88,12 @@ impl Easing { } } +impl Curve for Easing { + fn sample(&self, progress: f32) -> f32 { + Easing::sample(self, progress) + } +} + impl fmt::Debug for Easing { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -138,6 +171,12 @@ impl CubicBezier { } } +impl Curve for CubicBezier { + fn sample(&self, progress: f32) -> f32 { + (*self).sample(progress) + } +} + fn cubic(parameter: f32, first: f32, second: f32) -> f32 { let inverse = 1.0 - parameter; 3.0 * inverse * inverse * parameter * first @@ -193,6 +232,41 @@ impl Steps { } } +impl Curve for Steps { + fn sample(&self, progress: f32) -> f32 { + (*self).sample(progress) + } +} + +/// Ready-to-use curves for common interface motion. +pub mod curves { + use super::{CubicBezier, Easing}; + + /// Constant-speed progress. + pub const LINEAR: Easing = Easing::Linear; + /// Gentle acceleration from rest. + pub const EASE_IN: Easing = cubic(0.42, 0.0, 1.0, 1.0); + /// Gentle deceleration into the target. + pub const EASE_OUT: Easing = cubic(0.0, 0.0, 0.58, 1.0); + /// Symmetric acceleration and deceleration. + pub const EASE_IN_OUT: Easing = cubic(0.42, 0.0, 0.58, 1.0); + /// Balanced material-style movement for most interface changes. + pub const STANDARD: Easing = cubic(0.2, 0.0, 0.0, 1.0); + /// Fast exit from the current state followed by a soft arrival. + pub const EMPHASIZED: Easing = cubic(0.2, 0.8, 0.2, 1.0); + /// Quickly leaves the initial value. + pub const ACCELERATE: Easing = cubic(0.3, 0.0, 0.8, 0.15); + /// Quickly becomes visible and then settles gently. + pub const DECELERATE: Easing = cubic(0.05, 0.7, 0.1, 1.0); + /// Overshoots the target before settling on it. + pub const BACK_OUT: Easing = cubic(0.34, 1.56, 0.64, 1.0); + + /// Creates a constant cubic Bézier easing for the preset catalog. + const fn cubic(x1: f32, y1: f32, x2: f32, y2: f32) -> Easing { + Easing::CubicBezier(CubicBezier { x1, y1, x2, y2 }) + } +} + #[derive(Clone, Copy, Debug, PartialEq)] /// Input/output pair defining a point on piecewise-linear easing. pub struct LinearStop { diff --git a/crates/argui-animation/src/lib.rs b/crates/argui-animation/src/lib.rs index e9b804ed..ef060b2c 100644 --- a/crates/argui-animation/src/lib.rs +++ b/crates/argui-animation/src/lib.rs @@ -20,7 +20,9 @@ pub use clock::{Clock, ManualClock}; pub use composition::{Compose, Composition, Contribution, compose}; pub use controller::{Motion, MotionBinding, MotionState, MotionTrack, Tween}; pub use decay::{Decay, DecayConfig}; -pub use easing::{CubicBezier, Easing, EasingError, LinearStop, StepPosition, Steps}; +pub use easing::{ + CubicBezier, Curve, Easing, EasingError, LinearStop, StepPosition, Steps, curves, +}; pub use inertia::{Inertia, InertiaConfig, InertiaState}; pub use interpolate::Interpolate; pub use keyframe::{Keyframe, Keyframes}; diff --git a/crates/argui-animation/tests/easing.rs b/crates/argui-animation/tests/easing.rs index 1b685885..1829648f 100644 --- a/crates/argui-animation/tests/easing.rs +++ b/crates/argui-animation/tests/easing.rs @@ -1,4 +1,6 @@ -use argui_animation::{CubicBezier, Easing, EasingError, LinearStop, StepPosition, Steps}; +use argui_animation::{ + CubicBezier, Curve, Easing, EasingError, LinearStop, StepPosition, Steps, Tween, curves, +}; fn close(actual: f32, expected: f32) { assert!((actual - expected).abs() < 1.0e-4, "{actual} != {expected}"); @@ -106,3 +108,28 @@ fn piecewise_linear_easing_interpolates_and_supports_hard_stops() { )); } } + +#[derive(Clone, Copy)] +struct Quadratic; + +impl Curve for Quadratic { + fn sample(&self, progress: f32) -> f32 { + progress * progress + } +} + +#[test] +fn named_and_user_curves_share_the_easing_interface() { + close(Easing::curve(Quadratic).sample(0.5), 0.25); + close(curves::LINEAR.sample(0.4), 0.4); + close(Curve::sample(&curves::LINEAR, 0.4), 0.4); + assert!(curves::EASE_OUT.sample(0.5) > 0.5); + assert!(curves::BACK_OUT.sample(0.7) > 1.0); + let bezier = CubicBezier::new(0.0, 0.0, 1.0, 1.0).unwrap(); + close(Curve::sample(&bezier, 0.35), 0.35); + let steps = Steps::new(2, StepPosition::JumpEnd).unwrap(); + close(Curve::sample(&steps, 0.75), 0.5); + + let tween = Tween::new(argui_animation::Duration::from_millis(120)).curve(Quadratic); + close(tween.easing.sample(0.5), 0.25); +} diff --git a/crates/argui-widget-gallery/src/pages/motion.rs b/crates/argui-widget-gallery/src/pages/motion.rs index a289de99..fe00d6aa 100644 --- a/crates/argui-widget-gallery/src/pages/motion.rs +++ b/crates/argui-widget-gallery/src/pages/motion.rs @@ -1,24 +1,38 @@ +mod advanced; +mod implicit; +mod keyframes; +mod physics; + use argui::{ animation::{ - CubicBezier, Duration, Easing, FillMode, Interpolate, Keyframe, Keyframes, Spring, - SpringConfig, Timeline, Timing, + Direction, Duration, Easing, Inertia, InertiaConfig, Iterations, Keyframe, Keyframes, + Spring, SpringConfig, Time, Timeline, Timing, curves, }, - core::{Color, Transform2D, TransformOrigin}, + core::{Color, Transform2D}, paint::{Border, CornerRadii}, runtime::{Context, Render}, - ui::{AlignItems, Axes, Element, EventType, Overflow, Sides, UiEventKind, length, percent}, + ui::{ + AlignItems, Axes, Element, EventType, FlexWrap, Overflow, Sides, UiEventKind, length, + percent, + }, widgets::{Button, WidgetTheme, shadcn}, }; pub(crate) struct MotionDemo { spring: Spring, - spring_value: f32, + pub(super) spring_value: f32, + inertia: Inertia, + pub(super) inertia_value: f32, timeline: Timeline, - timeline_value: f32, - timeline_from: f32, - target: f32, + alternate: Timeline, + pub(super) timeline_value: f32, + pub(super) alternate_value: f32, + pub(super) target: f32, pending: bool, - reduced_motion: bool, + running: bool, + resume_pending: bool, + last_frame: Option