From c0876c2ef3884e767cc9fe1835a9661612fdcad7 Mon Sep 17 00:00:00 2001 From: Akin Aguda Date: Fri, 17 Jul 2026 11:13:49 -0500 Subject: [PATCH 1/3] When button now leaves view, it cancels animation --- src/components/button/mod.rs | 176 ++++++++++++++++++++++++++++++----- src/utils/render_loop.rs | 17 ++-- 2 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/components/button/mod.rs b/src/components/button/mod.rs index d4e3d09..3f64b5e 100644 --- a/src/components/button/mod.rs +++ b/src/components/button/mod.rs @@ -15,6 +15,7 @@ use crate::{ components::button::button_backdrop::{ ButtonBackdropBuilder, ButtonBackdropCfg, ButtonBackdropInstance, }, + hooks::use_in_view::{use_in_view, ElementVisibilityData, InViewOptions}, icons::right_arrow::RightArrow, utils::render_loop::RenderLoop, }; @@ -104,21 +105,49 @@ enum AnimationDirection { const TOTAL_ANIMATION_DURATION_MS: f64 = 500.0; -// My animaiton loop should pass in a value to my render funciton that ping pongs between 0 and 1 depending on the direction +type RenderLoopPtr = Rc>; + +/// Public handles to a running loop. These live *beside* the RenderLoop, not inside it, so +/// calling one only borrows the loop's RefCell fresh — nothing else is holding it at the time. +/// (Putting these on RenderLoop itself deadlocks: reaching the field needs a borrow, and the +/// handle then wants borrow_mut on the same cell.) +struct AnimationControls { + wake: Box, + sleep: Box, + // Tears the loop down when the component drops this (via the StoredValue). Replaces + // on_cleanup, whose Send + Sync bound an Rc/RefCell can't satisfy. + _handle: LoopHandle, +} + +/// Owns the loop and cancels + frees it on drop. +struct LoopHandle(RenderLoopPtr); + +impl Drop for LoopHandle { + fn drop(&mut self) { + let mut render_loop = self.0.borrow_mut(); + render_loop.cancel(); + // Drop the Closure so its captured Rc> clone is released — otherwise + // the RenderLoop <-> Closure cycle keeps the loop alive forever. + render_loop.closure = None; + } +} + +// Animaiton loop should pass in a value to render funciton that ping pongs between 0 and 1 depending on the direction // of the animation. Frowards goes from 0 -> 1 and backwards goes from 1 -> 0. The idea is that its basically just playing a // predefined animation but giving us a normalised time value between 0 and 1 -fn start_animaiton_loop(backdrop: ButtonBackdropInstance, direction: Rc>) { - let render_loop: Rc> = Rc::new(RefCell::new(RenderLoop::default())); - let window = web_sys::window().unwrap(); - let time = Cell::new(Date::now()); +fn create_render_loop( + backdrop: ButtonBackdropInstance, + direction: Rc>, +) -> AnimationControls { + let render_loop: RenderLoopPtr = Rc::new(RefCell::new(RenderLoop::default())); let timing = bezier(0.42, 0.0, 0.58, 1.0).unwrap(); + let time: Rc> = Rc::new(Cell::new(Date::now())); + let progression: Rc> = Rc::new(Cell::new(0.0)); + let running: Rc> = Rc::new(Cell::new(false)); - let progression = Cell::new(0.0); - - let closure: Closure = { - let window = web_sys::window().unwrap(); - let render_loop = render_loop.clone(); - Closure::wrap(Box::new(move |_| { + let render_fn: Rc = { + let (progression, time, direction) = (progression.clone(), time.clone(), direction.clone()); + Rc::new(move || { let now = Date::now(); let dt = now - time.get(); let multiplier = match direction.get() { @@ -128,16 +157,23 @@ fn start_animaiton_loop(backdrop: ButtonBackdropInstance, direction: Rc = { + let window = web_sys::window().unwrap(); + let (render_loop, render_fn) = (render_loop.clone(), render_fn.clone()); + + Closure::wrap(Box::new(move |_| { + render_fn(); let mut render_loop = render_loop.borrow_mut(); render_loop.animation_id = render_loop.closure.as_ref().map(|closure| { @@ -148,15 +184,69 @@ fn start_animaiton_loop(backdrop: ButtonBackdropInstance, direction: Rc = { + let window = web_sys::window().unwrap(); + let (render_loop, progression, time, running) = ( + render_loop.clone(), + progression.clone(), + time.clone(), + running.clone(), + ); + + Box::new(move || { + // Guard first: a redundant wake must not reset progression mid-animation or start a + // parallel rAF chain (which would run the animation at double speed). + if running.replace(true) { + return; + } + + // Fresh start from 0; reset the clock so the first frame doesn't see a stale dt. + progression.set(0.0); + time.set(Date::now()); + + let mut render_loop_borrow = render_loop.borrow_mut(); + if let Some(closure) = &render_loop_borrow.closure { + render_loop_borrow.animation_id = Some( + window + .request_animation_frame(closure.as_ref().unchecked_ref()) + .expect("cannot set animation frame"), + ); + } + }) + }; + + let sleep: Box = { + let (render_loop, progression, time, direction, running, render_fn) = ( + render_loop.clone(), + progression.clone(), + time.clone(), + direction.clone(), + running.clone(), + render_fn.clone(), + ); + + Box::new(move || { + running.set(false); + progression.set(0.0); + time.set(Date::now()); + direction.set(AnimationDirection::Backwards); + + // Paint the resting frame once so the canvas isn't left mid-animation / blank. + render_fn(); + + render_loop.borrow().cancel(); + }) + }; - render_loop.closure = Some(closure) + // No on_cleanup: teardown rides on LoopHandle's Drop, which runs when the StoredValue holding + // these controls is disposed on unmount (or replaced if this effect re-runs). + AnimationControls { + wake, + sleep, + _handle: LoopHandle(render_loop), + } } #[component] @@ -172,6 +262,10 @@ pub fn Button( let button_ref: NodeRef = NodeRef::new(); let link_ref: NodeRef = NodeRef::new(); + // StoredValue is Copy, so the effects below capture it directly — no Rc>> + // to clone around. new_local because the boxed closures aren't Send/Sync. + let controls = StoredValue::new_local(None::); + let class = ButtonVariants { size, color }.with_class(class); let icon_el = match icon { @@ -200,6 +294,16 @@ pub fn Button( let use_as_clone = use_as.clone(); + let ElementVisibilityData { + in_view: canvas_in_view, + } = use_in_view( + canvas_ref, + Some(InViewOptions { + trigger_once: Some(false), + ..Default::default() + }), + ); + // TODO - This should respond to resizing. We might also benefit from making the backdrop // struct also get it's extension from here @@ -229,12 +333,23 @@ pub fn Button( } }); + // Build the loop once canvas + backdrop builder are ready, apply the current visibility state + // immediately (untracked, so this effect stays keyed to canvas/builder only), then store it. + // The wake guard makes the initial wake safe even if the visibility effect also fires true. Effect::new(move || { if let Some(canvas) = canvas_ref.get() { if let Some(backdrop_builder) = maybe_backdrop_builder.get() { let backdrop = backdrop_builder.create_backdrop(canvas, ButtonBackdropCfg { color }); - start_animaiton_loop(backdrop, animation_direction.clone()); + let ctrls = create_render_loop(backdrop, animation_direction.clone()); + + if canvas_in_view.get_untracked() { + (ctrls.wake)(); + } else { + (ctrls.sleep)(); + } + + controls.set_value(Some(ctrls)); } } }); @@ -247,6 +362,21 @@ pub fn Button( } }); + // Drive wake/sleep off visibility. Read the signal FIRST and unconditionally, or the effect + // registers no dependency on its early-exit run and never fires again. + Effect::new(move || { + let in_view = canvas_in_view.get(); + controls.with_value(|c| { + if let Some(c) = c { + if in_view { + (c.wake)(); + } else { + (c.sleep)(); + } + } + }); + }); + match use_as { ButtonUsecase::Button { on_click } => Either::Left(view! { + + + }