use ribir_core::prelude::*;
#[must_use = "AnimateMatch does nothing if discarded immediately; bind it to a variable whose \
lifetime covers the animation scope"]
pub struct AnimateMatch {
subscription: Option<BoxedSubscription>,
animate: Box<dyn Animation>,
}
impl AnimateMatch {
pub fn run<V, S>(
value: impl StateWatcher<Value = V>, cases: MatchCases<V, S>,
transitions: impl Fn(&V, &V) -> Box<dyn Transition> + 'static, interruption: Interruption,
) -> Self
where
V: Clone + PartialEq + 'static,
S: AnimateState + 'static,
S::Value: Clone + 'static,
{
let init_value = value.read().clone();
let initial_target = cases.resolve(&init_value);
let animate_state = cases.state.clone_animate_state();
animate_state.revert(initial_target.clone());
let animate: Stateful<Animate<S>> = {
let mut d = Animate::declarer();
d.with_state(animate_state)
.with_from(initial_target);
d.finish()
};
let mut last_value = init_value;
let animate_writer = animate.clone_writer();
let subscription = watch!($read(value).clone())
.subscribe(move |to| {
if last_value != to {
let from = std::mem::replace(&mut last_value, to.clone());
if interruption == Interruption::Snap {
animate_writer.stop();
}
let target = cases.resolve(&to);
let transition = transitions(&from, &to);
let mut animate_ref = animate_writer.write();
animate_ref.transition = transition;
animate_ref.from = match interruption {
Interruption::Fluid => animate_ref.state.get(),
Interruption::Snap => {
let snap_from = cases.resolve(&from);
animate_ref.state.revert(snap_from.clone());
snap_from
}
};
animate_ref.state.set(target);
animate_ref.forget_modifies();
drop(animate_ref);
animate_writer.run();
}
})
.into_boxed();
Self { subscription: Some(subscription), animate: Box::new(animate) }
}
pub fn declarer<V: 'static, S: AnimateState + 'static>() -> AnimateMatchDeclarer<V, S> {
AnimateMatchDeclarer { value: None, cases: None, transitions: None, interruption: None }
}
pub fn stop(&mut self) {
self.animate.stop();
if let Some(sub) = self.subscription.take() {
sub.unsubscribe();
}
}
}
pub struct AnimateMatchDeclarer<V: 'static, S: AnimateState<Value: Clone> + 'static> {
value: Option<Box<dyn StateWatcher<Value = V>>>,
cases: Option<MatchCases<V, S>>,
transitions: Option<TransitionBuilder<V>>,
interruption: Option<Interruption>,
}
impl<V: 'static, S: AnimateState + 'static> AnimateMatchDeclarer<V, S> {
pub fn with_value(&mut self, value: impl StateWatcher<Value = V> + 'static) -> &mut Self {
self.value = Some(Box::new(value));
self
}
pub fn with_cases(&mut self, cases: MatchCases<V, S>) -> &mut Self {
self.cases = Some(cases);
self
}
pub fn with_transitions<F, T>(&mut self, transitions: F) -> &mut Self
where
F: Fn(&V, &V) -> T + 'static,
T: Transition + 'static,
{
self.transitions = Some(Box::new(move |f, t| Box::new(transitions(f, t))));
self
}
pub fn with_interruption(&mut self, interruption: Interruption) -> &mut Self {
self.interruption = Some(interruption);
self
}
}
impl<V, S> ObjDeclarer for AnimateMatchDeclarer<V, S>
where
V: Clone + PartialEq + 'static,
S: AnimateState<Value: Clone> + 'static,
{
type Target = AnimateMatch;
#[track_caller]
fn finish(self) -> Self::Target {
let value = self
.value
.expect("AnimateMatch requires a `value`");
let cases = self.cases.expect("AnimateMatch requires `cases`");
let transitions = self.transitions.unwrap_or_else(|| {
let transition =
EasingTransition { easing: easing::LINEAR, duration: Duration::from_millis(300) };
Box::new(move |_, _| Box::new(transition.clone()))
});
let interruption = self.interruption.unwrap_or_default();
AnimateMatch::run(value, cases, transitions, interruption)
}
}
pub struct MatchCases<V, S: AnimateState> {
state: S,
map: CaseResolver<V, S>,
}
pub type TransitionBuilder<V> = Box<dyn Fn(&V, &V) -> Box<dyn Transition>>;
pub type CaseResolver<V, S> = Box<dyn Fn(&V) -> <S as AnimateState>::Value>;
impl<V, S: AnimateState> MatchCases<V, S> {
pub fn new(state: S, map: impl Fn(&V) -> S::Value + 'static) -> Self {
Self { state, map: Box::new(map) }
}
#[inline]
fn resolve(&self, value: &V) -> S::Value { (self.map)(value) }
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Interruption {
#[default]
Fluid,
Snap,
}
#[macro_export]
macro_rules! cases {
(state: ($($state:expr),+ $(,)?), $($pattern:pat_param => ($($value:expr),+ $(,)?)),+ $(,)?) => {{
$crate::animation::animate_match::MatchCases::new(
$crate::core::animate_state_pack!($($state),+),
move |__value| match __value {
$( &$pattern => $crate::core::animate_state_pack!($($value),+) ),+
},
)
}};
(state: $state:expr, $($pattern:pat_param => $value:expr),+ $(,)?) => {{
$crate::animation::animate_match::MatchCases::new(
$state,
move |__value| match __value {
$( &$pattern => $value ),+
},
)
}};
}
pub use cases;
#[macro_export]
macro_rules! transitions {
() => {{
Box::new(move |_: &_, _: &_| -> Box<dyn $crate::core::prelude::Transition> {
Box::new($crate::core::prelude::EasingTransition {
easing: $crate::core::prelude::easing::LINEAR,
duration: $crate::core::prelude::Duration::from_millis(300),
})
}) as Box<dyn Fn(&_, &_) -> Box<dyn $crate::core::prelude::Transition>>
}};
($($pattern:pat => $transition:expr),+ $(,)?) => {{
Box::new(move |__from: &_, __to: &_| -> Box<dyn $crate::core::prelude::Transition> {
match (__from, __to) {
$(
$pattern => Box::new($transition),
)+
}
}) as Box<dyn Fn(&_, &_) -> Box<dyn $crate::core::prelude::Transition>>
}};
($closure:expr) => {{
Box::new(move |__from: &_, __to: &_| -> Box<dyn $crate::core::prelude::Transition> {
Box::new($closure(__from, __to))
}) as Box<dyn Fn(&_, &_) -> Box<dyn $crate::core::prelude::Transition>>
}};
}
pub use transitions;
#[cfg(test)]
mod tests {
use ribir_core::{reset_test_env, test_helper::*, window::WindowFlags};
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Status {
Idle,
Hover,
Active,
}
#[derive(Declare)]
struct ValueRecorder {
opacity: Stateful<f32>,
scale: Stateful<f32>,
frames: Stateful<Vec<(f32, f32)>>,
}
impl Render for ValueRecorder {
fn measure(&self, clamp: BoxClamp, _ctx: &mut MeasureCtx) -> Size { clamp.min }
fn paint(&self, _ctx: &mut PaintingCtx) {
self
.frames
.write()
.push((*self.opacity.read(), *self.scale.read()));
}
}
fn linear(duration_ms: u64) -> EasingTransition<impl Easing + Clone> {
EasingTransition { easing: easing::LINEAR, duration: Duration::from_millis(duration_ms) }
}
#[test]
fn initial_case_applies_absolute_targets() {
reset_test_env!();
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let opacity_reader = opacity.clone_reader();
let scale_reader = scale.clone_reader();
let frames_reader = frames.clone_reader();
let w = fn_widget! {
let _am = AnimateMatch::run(
Stateful::new(Status::Hover).clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (1.0, 1.0),
Status::Hover => (0.8, 1.1),
Status::Active => (0.5, 0.9),
},
transitions! {},
Interruption::default(),
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
assert_eq!(*opacity_reader.read(), 0.8);
assert_eq!(*scale_reader.read(), 1.1);
assert_eq!(frames_reader.read().last().copied(), Some((0.8, 1.1)));
}
#[test]
fn route_specific_transition_overrides_fallback() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = AnimateMatch::run(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.6, 1.05),
Status::Active => (1.0, 0.95),
},
transitions! {
(_, Status::Active) => linear(0),
_ => linear(200),
},
Interruption::default(),
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(frames_reader.read().last().copied(), Some((1.0, 0.95)));
}
#[test]
fn fluid_interruption_continues_from_interpolated_value() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let host = fn_widget! {
let _am = AnimateMatch::run(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.9),
},
transitions! { _ => linear(200), },
Interruption::Fluid,
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(host, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
std::thread::sleep(Duration::from_millis(80));
wnd.draw_frame();
let mid = frames_reader.read().last().copied().unwrap().0;
let animation_in_progress = mid > 0.1 && mid < 0.9;
*status.write() = Status::Hover;
wnd.draw_frame();
let resumed = frames_reader.read().last().copied().unwrap().0;
if animation_in_progress {
assert!(
(resumed - mid).abs() < 0.25,
"fluid interruption should continue near current value: mid={mid}, resumed={resumed}"
);
}
}
#[test]
fn snap_interruption_restarts_from_previous_case_target() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = AnimateMatch::run(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.9),
},
transitions! { _ => linear(200), },
Interruption::Snap,
);
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
std::thread::sleep(Duration::from_millis(80));
wnd.draw_frame();
*status.write() = Status::Hover;
wnd.draw_frame();
let restarted = frames_reader.read().last().copied().unwrap().0;
assert!(
restarted > 0.8,
"snap interruption should restart from previous case target, got {restarted}"
);
}
#[test]
fn stop_manually() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(1.0_f32);
let frames = Stateful::new(Vec::new());
let c_status = status.clone_writer();
let opacity_reader = opacity.clone_reader();
let w = fn_widget! {
let mut am = AnimateMatch::run(
c_status.clone_watcher(),
cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.5, 1.1),
Status::Active => (1.0, 0.95),
},
transitions! { _ => linear(100), },
Interruption::default(),
);
am.stop();
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(*opacity_reader.read(), 0.0);
}
#[test]
fn declarative_syntax() {
reset_test_env!();
let status = Stateful::new(Status::Idle);
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let opacity_reader = opacity.clone_reader();
let scale_reader = scale.clone_reader();
let frames_reader = frames.clone_reader();
let c_status = status.clone_writer();
let w = fn_widget! {
let _am = @AnimateMatch {
value: c_status.clone_watcher(),
cases: cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (0.0, 1.0),
Status::Hover => (0.6, 1.05),
Status::Active => (1.0, 0.95),
},
transitions: transitions! {
(_, Status::Active) => linear(0),
_ => linear(200),
},
interruption: Interruption::Fluid,
};
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
assert_eq!(*opacity_reader.read(), 0.0);
assert_eq!(*scale_reader.read(), 1.0);
*status.write() = Status::Active;
wnd.draw_frame();
assert_eq!(frames_reader.read().last().copied(), Some((1.0, 0.95)));
}
#[test]
fn declarative_with_defaults() {
reset_test_env!();
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let w = fn_widget! {
let _am = @AnimateMatch {
value: Stateful::new(Status::Hover).clone_watcher(),
cases: cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (1.0, 1.0),
Status::Hover => (0.8, 1.1),
Status::Active => (0.5, 0.9),
},
};
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
}
#[test]
fn declarative_with_closure_transition() {
reset_test_env!();
let opacity = Stateful::new(0.0_f32);
let scale = Stateful::new(0.0_f32);
let frames = Stateful::new(Vec::new());
let w = fn_widget! {
let _am = @AnimateMatch {
value: Stateful::new(Status::Hover).clone_watcher(),
cases: cases! {
state: (opacity.clone_writer(), scale.clone_writer()),
Status::Idle => (1.0, 1.0),
Status::Hover => (0.8, 1.1),
Status::Active => (0.5, 0.9),
},
transitions: transitions!(|_, _| linear(200)),
};
@ValueRecorder {
opacity: opacity.clone_writer(),
scale: scale.clone_writer(),
frames: frames.clone_writer(),
}
};
let wnd = TestWindow::new(w, Size::new(100., 100.), WindowFlags::ANIMATIONS);
wnd.draw_frame();
}
}