use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use reactive_core::{ReadSignal, RwSignal, signal};
use crate::curve::{Curve, Spring, Tween};
use crate::lerp::Lerp;
use crate::ticker::{self, Tickable};
const NOOP_EPS_SQ: f32 = 1e-12;
const MAX_SUBSTEP: f32 = 1.0 / 240.0;
const MAX_FRAME_DT: f32 = 0.1;
const MIN_FRAME_DT: f32 = 1.0 / 1000.0;
const DISP_EPS_SQ: f32 = 1e-6;
const VEL_EPS_SQ: f32 = 1e-6;
const MIN_MASS: f32 = 1e-4;
pub(crate) struct AnimInner<T: Lerp + 'static> {
signal: RwSignal<T>,
current: T,
target: T,
velocity: T,
start: T,
elapsed_secs: f32,
curve: Curve,
settled: bool,
last: Option<Instant>,
}
impl<T: Lerp + 'static> AnimInner<T> {
fn integrate(&mut self, now: Instant, scale: f32) -> Option<T> {
if self.settled {
return None;
}
if scale <= 0.0 {
return Some(self.snap_to_target());
}
let last = match self.last {
Some(last) => last,
None => {
self.last = Some(now);
return None;
}
};
let dt = now.saturating_duration_since(last).as_secs_f32() * scale;
if dt < MIN_FRAME_DT {
return None;
}
self.last = Some(now);
match self.curve {
Curve::Tween(t) => self.step_tween(t, dt),
Curve::Spring(s) => self.step_spring(s, dt),
}
}
fn step_tween(&mut self, t: Tween, dt: f32) -> Option<T> {
self.elapsed_secs += dt;
let duration = t.duration.as_secs_f32();
if duration <= 0.0 || self.elapsed_secs >= duration {
return Some(self.snap_to_target());
}
let eased = t.easing.apply(self.elapsed_secs / duration);
self.current = self.start.lerp(&self.target, eased);
Some(self.current.clone())
}
fn step_spring(&mut self, s: Spring, dt: f32) -> Option<T> {
let dt = dt.min(MAX_FRAME_DT);
let steps = (dt / MAX_SUBSTEP).ceil().max(1.0) as u32;
let h = dt / steps as f32;
let mass = s.mass.max(MIN_MASS);
let before = self.current.clone();
for _ in 0..steps {
let displacement = self.current.sub(&self.target);
let force = displacement
.scale(-s.stiffness)
.sub(&self.velocity.scale(s.damping));
let accel = force.scale(1.0 / mass);
self.velocity = self.velocity.add(&accel.scale(h));
self.current = self.current.add(&self.velocity.scale(h));
}
let arrived = self.current.sub(&self.target).magnitude_sq() < DISP_EPS_SQ
&& self.velocity.magnitude_sq() < VEL_EPS_SQ;
if arrived || self.value_is_frozen(&before) {
return Some(self.snap_to_target());
}
Some(self.current.clone())
}
fn value_is_frozen(&self, before: &T) -> bool {
self.current.sub(before).magnitude_sq() == 0.0
}
fn snap_to_target(&mut self) -> T {
self.current = self.target.clone();
self.velocity = T::zero();
self.settled = true;
self.current.clone()
}
}
impl<T: Lerp + 'static> Tickable for RefCell<AnimInner<T>> {
fn tick(&self, now: Instant, scale: f32) {
let (signal, value) = {
let mut inner = self.borrow_mut();
let value = inner.integrate(now, scale);
(inner.signal.clone(), value)
};
if let Some(value) = value {
signal.set(value);
}
}
fn is_settled(&self) -> bool {
self.borrow().settled
}
}
pub struct Animated<T: Lerp + 'static> {
inner: Rc<RefCell<AnimInner<T>>>,
id: u64,
}
impl<T: Lerp + 'static> Clone for Animated<T> {
fn clone(&self) -> Self {
Animated {
inner: Rc::clone(&self.inner),
id: self.id,
}
}
}
impl<T: Lerp + 'static> Animated<T> {
pub fn new(initial: T, curve: impl Into<Curve>) -> Self {
let signal = signal(initial.clone());
let inner = Rc::new(RefCell::new(AnimInner {
signal,
current: initial.clone(),
target: initial.clone(),
velocity: T::zero(),
start: initial,
elapsed_secs: 0.0,
curve: curve.into(),
settled: true,
last: None,
}));
Animated {
inner,
id: ticker::next_id(),
}
}
pub fn retarget(&self, target: T) {
{
let mut inner = self.inner.borrow_mut();
if target.sub(&inner.target).magnitude_sq() <= NOOP_EPS_SQ {
return;
}
match inner.curve {
Curve::Spring(_) => {
inner.target = target;
}
Curve::Tween(_) => {
inner.start = inner.current.clone();
inner.elapsed_secs = 0.0;
inner.target = target;
}
}
inner.settled = false;
inner.last = None;
}
let weak = Rc::downgrade(&self.inner);
ticker::register(self.id, weak);
}
pub fn get(&self) -> T {
self.inner.borrow().signal.get()
}
pub fn read(&self) -> ReadSignal<T> {
self.inner.borrow().signal.read_only()
}
pub fn is_settled(&self) -> bool {
self.inner.borrow().settled
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::curve::{spring, tween};
use crate::easing::Easing;
use crate::ticker::{has_active, reset, set_scale, tick};
use geometry_core::Rect;
fn fresh() -> Instant {
reset();
set_scale(1.0);
Instant::now()
}
#[test]
fn tween_reaches_eased_value_at_half_duration() {
let base = fresh();
let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::EaseInOut));
a.retarget(1.0);
tick(base);
tick(base + Duration::from_millis(100));
let expected = 0.0f32.lerp(&1.0, Easing::EaseInOut.apply(0.5));
assert!(
(a.get() - expected).abs() < 1e-4,
"{} != {expected}",
a.get()
);
}
#[test]
fn tween_settles_at_target_and_goes_inactive() {
let base = fresh();
let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
a.retarget(1.0);
tick(base);
tick(base + Duration::from_millis(200));
assert!((a.get() - 1.0).abs() < 1e-6);
assert!(a.is_settled());
assert!(!has_active());
}
#[test]
fn first_tick_does_not_move_the_value() {
let base = fresh();
let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
a.retarget(1.0);
tick(base);
assert_eq!(a.get(), 0.0);
assert!(has_active());
}
#[test]
fn spring_settles_at_target() {
let base = fresh();
let a = Animated::new(0.0f32, spring(120.0, 22.0));
a.retarget(1.0);
tick(base);
let mut now = base;
for _ in 0..1000 {
now += Duration::from_millis(16);
tick(now);
if !has_active() {
break;
}
}
assert!(!has_active(), "spring never settled");
assert!((a.get() - 1.0).abs() < 1e-2, "settled at {}", a.get());
}
#[test]
fn spring_preserves_velocity_across_retarget() {
let base = fresh();
let a = Animated::new(0.0f32, spring(120.0, 14.0));
a.retarget(1.0);
tick(base);
tick(base + Duration::from_millis(16));
tick(base + Duration::from_millis(32));
let value_before = a.get();
a.retarget(value_before);
tick(base + Duration::from_millis(33));
tick(base + Duration::from_millis(37));
assert!(
a.get() > value_before,
"momentum lost: {} !> {value_before}",
a.get()
);
}
#[test]
fn retarget_to_current_goal_is_a_noop() {
let _ = fresh();
let a = Animated::new(5.0f32, tween(Duration::from_millis(200), Easing::Linear));
a.retarget(5.0);
assert!(a.is_settled());
assert!(!has_active());
}
#[test]
fn same_target_retarget_does_not_restart_the_tween() {
let base = fresh();
let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
a.retarget(1.0);
tick(base);
tick(base + Duration::from_millis(100));
assert!((a.get() - 0.5).abs() < 1e-4);
a.retarget(1.0);
tick(base + Duration::from_millis(150));
assert!((a.get() - 0.75).abs() < 1e-4, "restarted: {}", a.get());
}
#[test]
fn scale_zero_jumps_straight_to_target() {
let base = fresh();
set_scale(0.0);
let a = Animated::new(0.0f32, spring(120.0, 14.0));
a.retarget(1.0);
tick(base);
assert_eq!(a.get(), 1.0);
assert!(a.is_settled());
assert!(!has_active());
set_scale(1.0);
}
#[test]
fn spring_on_large_coordinates_stops_being_active() {
let base = fresh();
let a = Animated::new(Rect::new(1920.0, 1080.0, 240.0, 64.0), spring(180.0, 26.0));
a.retarget(Rect::new(2400.0, 1080.0, 240.0, 64.0));
tick(base);
let mut now = base;
for _ in 0..600 {
now += Duration::from_micros(16_667);
tick(now);
if !has_active() {
break;
}
}
assert!(!has_active(), "spring never deregistered: {:?}", a.get());
assert!(
(a.get().x - 2400.0).abs() < 1e-2,
"settled at {:?}",
a.get()
);
}
#[test]
fn sub_frame_ticks_do_not_consume_elapsed_time() {
let base = fresh();
let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
a.retarget(1.0);
tick(base);
for extra in 1..7u64 {
tick(base + Duration::from_micros(extra * 20));
}
assert_eq!(a.get(), 0.0, "a sub-frame tick moved the value");
tick(base + Duration::from_millis(100));
assert!(
(a.get() - 0.5).abs() < 1e-3,
"elapsed time was lost to the sub-frame ticks: {}",
a.get()
);
}
#[test]
fn dropped_animation_deregisters() {
let base = fresh();
let a = Animated::new(0.0f32, tween(Duration::from_millis(200), Easing::Linear));
a.retarget(1.0);
tick(base);
assert!(has_active());
drop(a);
assert!(!has_active());
}
}