use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionPreference {
Animated,
Reduced,
}
impl MotionPreference {
pub const fn is_immediate(self) -> bool {
matches!(self, Self::Reduced)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionDuration {
Immediate,
Short,
Medium,
Long,
Custom(Duration),
}
impl MotionDuration {
pub const fn as_duration(self) -> Duration {
match self {
Self::Immediate => Duration::from_millis(0),
Self::Short => Duration::from_millis(120),
Self::Medium => Duration::from_millis(180),
Self::Long => Duration::from_millis(260),
Self::Custom(duration) => duration,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MotionEasing {
Linear,
EaseOut,
EaseInOut,
EaseOutStrong,
EaseInOutStrong,
}
impl MotionEasing {
pub fn sample(self, progress: f32) -> f32 {
let progress = progress.clamp(0.0, 1.0);
match self {
Self::Linear => progress,
Self::EaseOut => 1.0 - (1.0 - progress).powi(3),
Self::EaseInOut => {
if progress < 0.5 {
4.0 * progress.powi(3)
} else {
1.0 - (-2.0 * progress + 2.0).powi(3) / 2.0
}
}
Self::EaseOutStrong => 1.0 - (1.0 - progress).powi(4),
Self::EaseInOutStrong => {
if progress < 0.5 {
8.0 * progress.powi(4)
} else {
1.0 - (-2.0 * progress + 2.0).powi(4) / 2.0
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MotionSpec {
preference: MotionPreference,
duration: MotionDuration,
easing: MotionEasing,
}
impl MotionSpec {
pub const fn new(
preference: MotionPreference,
duration: MotionDuration,
easing: MotionEasing,
) -> Self {
Self {
preference,
duration,
easing,
}
}
pub const fn layout(preference: MotionPreference) -> Self {
Self::new(preference, MotionDuration::Medium, MotionEasing::EaseOut)
}
pub const fn affordance(preference: MotionPreference) -> Self {
Self::new(preference, MotionDuration::Short, MotionEasing::EaseOut)
}
pub const fn committed_layout(preference: MotionPreference) -> Self {
Self::new(
preference,
MotionDuration::Medium,
MotionEasing::EaseOutStrong,
)
}
pub const fn continuity(preference: MotionPreference) -> Self {
Self::new(
preference,
MotionDuration::Long,
MotionEasing::EaseInOutStrong,
)
}
pub const fn immediate() -> Self {
Self::new(
MotionPreference::Reduced,
MotionDuration::Immediate,
MotionEasing::Linear,
)
}
pub const fn preference(self) -> MotionPreference {
self.preference
}
pub const fn duration(self) -> MotionDuration {
if self.preference.is_immediate() {
MotionDuration::Immediate
} else {
self.duration
}
}
pub const fn easing(self) -> MotionEasing {
self.easing
}
pub const fn is_immediate(self) -> bool {
self.preference.is_immediate() || matches!(self.duration(), MotionDuration::Immediate)
}
pub const fn allows_spatial_motion(self) -> bool {
!self.is_immediate()
}
pub fn progress_at(self, elapsed: Duration) -> f32 {
if self.is_immediate() {
return 1.0;
}
let duration = self.duration().as_duration();
if duration.is_zero() {
return 1.0;
}
let raw = (elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0);
self.easing().sample(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reduced_motion_forces_immediate_duration() {
let spec = MotionSpec::layout(MotionPreference::Reduced);
assert!(spec.is_immediate());
assert_eq!(spec.duration(), MotionDuration::Immediate);
assert_eq!(spec.duration().as_duration(), Duration::from_millis(0));
}
#[test]
fn animated_motion_preserves_duration_and_easing() {
let spec = MotionSpec::new(
MotionPreference::Animated,
MotionDuration::Long,
MotionEasing::EaseInOut,
);
assert!(!spec.is_immediate());
assert_eq!(spec.duration(), MotionDuration::Long);
assert_eq!(spec.easing(), MotionEasing::EaseInOut);
assert_eq!(spec.duration().as_duration(), Duration::from_millis(260));
}
#[test]
fn progress_at_samples_duration_and_curve() {
let spec = MotionSpec::new(
MotionPreference::Animated,
MotionDuration::Custom(Duration::from_millis(200)),
MotionEasing::Linear,
);
assert_eq!(spec.progress_at(Duration::from_millis(0)), 0.0);
assert_eq!(spec.progress_at(Duration::from_millis(100)), 0.5);
assert_eq!(spec.progress_at(Duration::from_millis(250)), 1.0);
}
#[test]
fn named_layout_specs_use_stronger_curves_without_changing_layout_default() {
assert_eq!(
MotionSpec::layout(MotionPreference::Animated).easing(),
MotionEasing::EaseOut
);
assert_eq!(
MotionSpec::affordance(MotionPreference::Animated).duration(),
MotionDuration::Short
);
assert_eq!(
MotionSpec::committed_layout(MotionPreference::Animated).easing(),
MotionEasing::EaseOutStrong
);
assert_eq!(
MotionSpec::continuity(MotionPreference::Animated).easing(),
MotionEasing::EaseInOutStrong
);
}
#[test]
fn reduced_motion_disables_spatial_motion() {
assert!(!MotionSpec::layout(MotionPreference::Reduced).allows_spatial_motion());
assert!(MotionSpec::layout(MotionPreference::Animated).allows_spatial_motion());
}
#[test]
fn strong_motion_curves_are_monotonic_and_complete() {
for easing in [MotionEasing::EaseOutStrong, MotionEasing::EaseInOutStrong] {
let samples = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0]
.into_iter()
.map(|progress| easing.sample(progress))
.collect::<Vec<_>>();
assert_eq!(samples.first().copied(), Some(0.0));
assert_eq!(samples.last().copied(), Some(1.0));
assert!(
samples.windows(2).all(|window| window[0] <= window[1]),
"{easing:?} should be monotonic: {samples:?}"
);
}
}
}