Skip to content

Instantly share code, notes, and snippets.

@gpollo
Created May 21, 2024 01:10
Show Gist options
  • Save gpollo/dd190c4b67849e7f9538b4c268b179b2 to your computer and use it in GitHub Desktop.
Save gpollo/dd190c4b67849e7f9538b4c268b179b2 to your computer and use it in GitHub Desktop.
Leptos component that replicates Vue's <Transition>
use std::cell::{Cell, RefCell};
use std::ops::Deref;
use std::rc::Rc;
use leptos::leptos_dom::helpers::AnimationFrameRequestHandle;
use leptos::*;
use web_sys::EventTarget;
/// Possible states of the transition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum State {
/// State when children are hidden.
Hidden,
/// State when the children are inserted.
EnteringStart { handle: AnimationFrameRequestHandle },
/// State when `l-enter-from` is applied.
EnteringFrom { handle: AnimationFrameRequestHandle },
/// State when `l-enter-to` is applied.
EnteringTo,
/// State when entering transition is cancelled and rolled back.
EnteringCancel,
/// State when children are visible.
Visible,
/// State when `l-leave-from` is applied.
ExitingFrom { handle: AnimationFrameRequestHandle },
/// State when `l-leave-to` is applied.
ExitingTo,
/// State when exiting transition is cancelled and rolled back.
ExitingCancel,
}
impl State {
fn new(initial_when: bool) -> Self {
if initial_when {
State::Visible
} else {
State::Hidden
}
}
fn classes(&self) -> Vec<&'static str> {
let mut classes = Vec::new();
if matches!(self, State::EnteringFrom { .. } | State::EnteringCancel) {
classes.push("transition-enter-from");
}
if matches!(self, State::EnteringTo) {
classes.push("transition-enter-to");
}
if matches!(
self,
State::EnteringFrom { .. } | State::EnteringTo | State::EnteringCancel
) {
classes.push("transition-enter-active");
}
if matches!(self, State::ExitingFrom { .. } | State::ExitingCancel) {
classes.push("transition-leave-from");
}
if matches!(self, State::ExitingTo) {
classes.push("transition-leave-to");
}
if matches!(
self,
State::ExitingFrom { .. } | State::ExitingTo | State::ExitingCancel
) {
classes.push("transition-leave-active");
}
classes
}
}
/// State machine for controlling the animation.
///
/// TODO: Add a timeout in case there are no animation and the state machine gets stuck waiting.
///
#[doc = svgbobdoc::transform!(
/// ```svgbob
/// .---------------. .-------------. .---------------.
/// | | | |---------->| |
/// .---------------------->| Hidden |<------------------| ExitingTo | @show | ExitingCancel |
/// | @transition | | @transition | |<----------| |
/// | '------------+--' '-------------' @hide '-------+-------'
/// | ^ @hide | ^ @frame |
/// | | v @show | |
/// | .--+------------. .------+------. |
/// | | | | | |
/// | | EnteringStart | | ExitingFrom | |
/// | | | | | |
/// | '-------+-------' '----------+--' |
/// | | ^ @hide | |
/// | v @frame | | |
/// | .---------------. | | |
/// | | | | | |
/// | .----------------| EnteringFrom | | | |
/// | | | | | | |
/// | | '-------+-------' | | |
/// | | | | | |
/// | v @hide v @frame | v @show |
/// .----+-----------. .---------------. .--+-------+--. |
/// | |<----------| | | | |
/// | EnteringCancel | @hide | EnteringTo |------------------>| Visible |<------------------'
/// | |---------->| | @transition | | @transition
/// '----------------' @show '---------------' '-------------'
/// ```
)]
struct StateMachine {
state: ReadSignal<State>,
set_state: WriteSignal<State>,
set_visible: WriteSignal<bool>,
}
impl StateMachine {
fn new(initial_when: bool) -> (Rc<Self>, ReadSignal<State>, ReadSignal<bool>) {
let (state, set_state) = create_signal(State::new(initial_when));
let (visible, set_visible) = create_signal(initial_when);
let state_machine = Rc::new(Self {
state: state.clone(),
set_state: set_state.clone(),
set_visible: set_visible.clone(),
});
(state_machine, state, visible)
}
fn configure_animation_frame(self: &Rc<Self>) -> AnimationFrameRequestHandle {
let this = self.clone();
request_animation_frame_with_handle(move || this.on_animation_frame()).unwrap()
}
fn on_show(self: &Rc<Self>) {
match self.state.get_untracked() {
State::Hidden => {
self.set_visible.set(true);
self.set_state.set(State::EnteringStart {
handle: self.configure_animation_frame(),
});
}
State::ExitingFrom { handle } => {
handle.cancel();
self.set_visible.set(false);
self.set_state.set(State::Visible);
}
State::ExitingTo => {
self.set_state.set(State::ExitingCancel);
}
State::EnteringCancel => {
self.set_state.set(State::EnteringTo);
}
_ => (),
}
}
fn on_hide(self: &Rc<Self>) {
match self.state.get_untracked() {
State::EnteringStart { handle } => {
handle.cancel();
self.set_visible.set(false);
self.set_state.set(State::Hidden);
}
State::EnteringFrom { handle } => {
handle.cancel();
self.set_state.set(State::EnteringCancel);
}
State::EnteringTo => {
self.set_state.set(State::EnteringCancel);
}
State::Visible => {
self.set_state.set(State::ExitingFrom {
handle: self.configure_animation_frame(),
});
}
State::ExitingCancel => {
self.set_state.set(State::ExitingTo);
}
_ => (),
}
}
fn on_command(self: &Rc<Self>, when: bool) {
if when {
self.on_show();
} else {
self.on_hide();
}
}
fn on_animation_frame(self: &Rc<Self>) {
match self.state.get_untracked() {
State::EnteringStart { .. } => {
self.set_state.set(State::EnteringFrom {
handle: self.configure_animation_frame(),
});
}
State::EnteringFrom { .. } => {
self.set_state.set(State::EnteringTo);
}
State::ExitingFrom { .. } => {
self.set_state.set(State::ExitingTo);
}
_ => (),
}
}
fn on_transition_end(self: &Rc<Self>) {
match self.state.get_untracked() {
State::EnteringTo => {
self.set_state.set(State::Visible);
}
State::EnteringCancel => {
self.set_visible.set(false);
self.set_state.set(State::Hidden);
}
State::ExitingTo => {
self.set_visible.set(false);
self.set_state.set(State::Hidden);
}
State::ExitingCancel => {
self.set_state.set(State::Visible);
}
_ => (),
}
}
}
/// Keeps track of ongoing transitions in HTML elements.
///
/// For each HTML element that is configured, it adds event handler for the [`transitionstart`],
/// [`transitioncancel`] and [`transitionend`] events. When all animations are finished, it executes
/// [`StateMachine::on_transition_end`].
///
/// [`transitionstart`]: https://developer.mozilla.org/en-US/docs/Web/API/Element/transitionstart_event
/// [`transitioncancel`]: https://developer.mozilla.org/en-US/docs/Web/API/Element/transitioncancel_event
/// [`transitionend`]: https://developer.mozilla.org/en-US/docs/Web/API/Element/transitionend_event
struct TransitionWatcher {
targets: RefCell<Vec<EventTarget>>,
counter: Cell<u32>,
state: Rc<StateMachine>,
}
impl TransitionWatcher {
pub fn new(state: &Rc<StateMachine>) -> Rc<Self> {
Rc::new(Self {
targets: RefCell::new(vec![]),
counter: Cell::new(0),
state: state.clone(),
})
}
fn configure(
self: &Rc<Self>,
element: HtmlElement<html::AnyElement>,
) -> HtmlElement<html::AnyElement> {
match self.targets.try_borrow_mut() {
Ok(mut targets) => {
let element: web_sys::HtmlElement = element.deref().clone();
targets.push(element.into());
}
Err(e) => {
leptos::logging::error!("{}", e.to_string());
return element;
}
}
let this_start = self.clone();
let this_cancel = self.clone();
let this_end = self.clone();
element
.on::<ev::transitionstart>(ev::transitionstart, move |event| {
this_start.on_transition_start(event.target())
})
.on::<ev::transitioncancel>(ev::transitioncancel, move |event| {
this_cancel.on_transition_cancel(event.target())
})
.on::<ev::transitionend>(ev::transitionend, move |event| {
this_end.on_transition_end(event.target())
})
}
fn on_transition_start(&self, target: Option<EventTarget>) {
if let Some(target) = target {
if self.targets.borrow().contains(&target) {
self.counter.set(self.counter.get() + 1);
}
}
}
fn on_transition_cancel(&self, target: Option<EventTarget>) {
if let Some(target) = target {
if self.targets.borrow().contains(&target) {
self.counter.set(self.counter.get() - 1);
}
}
}
fn on_transition_end(&self, target: Option<EventTarget>) {
if let Some(target) = target {
if self.targets.borrow().contains(&target) {
self.counter.set(self.counter.get() - 1);
if self.counter.get() == 0 {
self.state.on_transition_end();
}
}
}
}
}
/// Configure the command callback for the state machine.
///
/// This callback is triggered when the user want to show or hide the children.
fn configure_command_callback(state_machine: &Rc<StateMachine>, when: &Memo<bool>) {
let state_machine = state_machine.clone();
let when = when.clone();
create_effect(move |_| state_machine.on_command(when.get()));
}
/// Configure the transition end callback for the state machine.
///
/// This callback is triggered when all animations of the lower-level children are finished.
/// Internally, it uses `TransitionWatch` type of keep track of the ongoing animations.
fn configure_transition_callback(
state_machine: Rc<StateMachine>,
state: ReadSignal<State>,
children: ChildrenFn,
class: String,
) -> impl Fn() -> View {
move || {
let class = class.clone();
let watcher = TransitionWatcher::new(&state_machine);
children()
.nodes
.iter()
.map(move |node| {
let class = class.clone();
match node {
View::Element(element) => watcher
.configure(element.clone().into_html_element())
.dyn_classes(move || state.get().classes())
.classes(class)
.into_view(),
view => view.clone(),
}
})
.collect::<Vec<_>>()
.into_view()
}
}
/// A component that replicates the behavior of Vue's [`<Transition>`].
///
/// Unlike Vue, we don't have named transitions and we use the following
/// class names:
///
/// * `transition-enter-from`,
/// * `transition-enter-to`,
/// * `transition-enter-active`
/// * `transition-leave-from`,
/// * `transition-leave-to` and
/// * `transition-leave-active`.
///
/// This component will apply CSS classes upon entering and leaving transitions
/// for every direct children of the `<VueTransition>`. For example, let's say
/// we have a background and a sheet that we wish to animate.
///
/// ```html
/// <VueTransition when=show>
/// <div class="background"></div>
/// <div class="sheet">
/// ...
/// </div>
/// </VueTransition>
/// ```
///
/// To apply different transitions between the background and the sheet, we
/// can simply use different CSS selectors. In our case, we want to animate
/// the background opacity and the sheet position.
///
/// ```css
/// .background.transition-enter-active,
/// .background.transition-leave-active {
/// transition: opacity 200ms ease;
/// }
///
/// .background.transition-enter-from,
/// .background.transition-leave-to {
/// opacity: 0;
/// }
///
/// .background.transition-enter-to,
/// .background.transition-leave-from {
/// opacity: 1;
/// }
///
/// .sheet.transition-enter-active,
/// .sheet.transition-leave-active {
/// transition: transform 500ms ease;
/// }
///
/// .sheet.transition-enter-from,
/// .sheet.transition-leave-to {
/// transform: translateY(100%);
/// }
///
/// .sheet.transition-enter-to,
/// .sheet.transition-leave-from {
/// transform: translateY(0%);
/// }
/// ```
///
/// [`<Transition>`]: https://vuejs.org/guide/built-ins/transition
#[component]
pub fn VueTransition(
/// The components that we want to show.
children: ChildrenFn,
/// If the component should show or not.
when: ReadSignal<bool>,
/// Optional CSS class to apply, useful for scoped CSS.
#[prop(optional, default = "".to_string())] class: String,
) -> impl IntoView {
let (state_machine, state, visible) = StateMachine::new(when.get_untracked());
let when = create_memo(move |_| when());
configure_command_callback(&state_machine, &when);
let children = configure_transition_callback(state_machine, state, children, class);
view! {
{
move || {
match visible.get() {
false => view! {}.into_view(),
true => children(),
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment