feat: add the ability to specify animations on route transitions (#736)

This commit is contained in:
Greg Johnston 2023-04-14 18:20:42 -04:00 committed by GitHub
parent 8a6d129575
commit 7382c7e51c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 567 additions and 45 deletions

View file

@ -3,17 +3,7 @@
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
<style>
a[aria-current] {
font-weight: bold;
}
.contact, .contact-list {
border: 1px solid #c0c0c0;
border-radius: 3px;
padding: 1rem;
}
</style>
<link data-trunk rel="css" href="style.css"/>
</head>
<body></body>
</html>
</html>

View file

@ -27,7 +27,12 @@ pub fn RouterExample(cx: Scope) -> impl IntoView {
<A href="redirect-home">"Redirect to Home"</A>
</nav>
<main>
<Routes>
<AnimatedRoutes
outro="slideOut"
intro="slideIn"
outro_back="slideOutBack"
intro_back="slideInBack"
>
<ContactRoutes/>
<Route
path="about"
@ -41,7 +46,7 @@ pub fn RouterExample(cx: Scope) -> impl IntoView {
path="redirect-home"
view=move |cx| view! { cx, <Redirect path="/"/> }
/>
</Routes>
</AnimatedRoutes>
</main>
</Router>
}
@ -102,7 +107,7 @@ pub fn ContactList(cx: Scope) -> impl IntoView {
<Suspense fallback=move || view! { cx, <p>"Loading contacts..."</p> }>
{move || view! { cx, <ul>{contacts}</ul>}}
</Suspense>
<Outlet/>
<AnimatedOutlet outro="fadeOut" intro="fadeIn"/>
</div>
}
}

91
examples/router/style.css Normal file
View file

@ -0,0 +1,91 @@
a[aria-current] {
font-weight: bold;
}
.contact, .contact-list {
border: 1px solid #c0c0c0;
border-radius: 3px;
padding: 1rem;
}
.fadeIn {
animation: 0.5s fadeIn forwards;
}
.fadeOut {
animation: 0.5s fadeOut forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.slideIn {
animation: 0.125s slideIn forwards;
}
.slideOut {
animation: 0.125s slideOut forwards;
}
@keyframes slideIn {
from {
transform: translate(100vw, 0);
}
to {
transform: translate(0px, 0px);
}
}
@keyframes slideOut {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(-100vw, 0);
}
}
.slideInBack {
animation: 0.125s slideInBack forwards;
}
.slideOutBack {
animation: 0.125s slideOutBack forwards;
}
@keyframes slideInBack {
from {
transform: translate(-100vw, 0);
}
to {
transform: translate(0px, 0px);
}
}
@keyframes slideOutBack {
from {
transform: translate(0px, 0px);
}
to {
transform: translate(100vw, 0);
}
}

96
router/src/animation.rs Normal file
View file

@ -0,0 +1,96 @@
/// Configures what animation should be shown when transitioning
/// between two root routes. Defaults to `None`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Animation {
/// Class set when a route is first painted.
pub start: Option<&'static str>,
/// Class set when a route is fading out.
pub outro: Option<&'static str>,
/// Class set when a route is fading in.
pub intro: Option<&'static str>,
/// Class set when a route is fading out, if its a “back” navigation.
pub outro_back: Option<&'static str>,
/// Class set when a route is fading in, if its a “back” navigation.
pub intro_back: Option<&'static str>,
/// Class set when all animations have finished.
pub finally: Option<&'static str>,
}
impl Animation {
pub(crate) fn next_state(
&self,
current: &AnimationState,
is_back: bool,
) -> (AnimationState, bool) {
let Animation {
start,
outro,
intro,
intro_back,
..
} = self;
match current {
AnimationState::Outro => {
let next = if start.is_some() {
AnimationState::Start
} else if intro.is_some() {
AnimationState::Intro
} else {
AnimationState::Finally
};
(next, true)
}
AnimationState::OutroBack => {
let next = if start.is_some() {
AnimationState::Start
} else if intro_back.is_some() {
AnimationState::IntroBack
} else if intro.is_some() {
AnimationState::Intro
} else {
AnimationState::Finally
};
(next, true)
}
AnimationState::Start => {
let next = if intro.is_some() {
AnimationState::Intro
} else {
AnimationState::Finally
};
(next, false)
}
AnimationState::Intro => (AnimationState::Finally, false),
AnimationState::IntroBack => (AnimationState::Finally, false),
AnimationState::Finally => {
if outro.is_some() {
if is_back {
(AnimationState::OutroBack, false)
} else {
(AnimationState::Outro, false)
}
} else if start.is_some() {
(AnimationState::Start, true)
} else if intro.is_some() {
if is_back {
(AnimationState::IntroBack, false)
} else {
(AnimationState::Intro, false)
}
} else {
(AnimationState::Finally, true)
}
}
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug, PartialOrd, Ord)]
pub(crate) enum AnimationState {
Outro,
OutroBack,
Start,
Intro,
IntroBack,
Finally,
}

View file

@ -1,6 +1,10 @@
use crate::use_route;
use crate::{
animation::{Animation, AnimationState},
use_is_back_navigation, use_route,
};
use leptos::{leptos_dom::HydrationCtx, *};
use std::{cell::Cell, rc::Rc};
use web_sys::AnimationEvent;
/// Displays the child route nested in a parent route, allowing you to control exactly where
/// that child route is displayed. Renders nothing if there is no nested child.
@ -39,3 +43,132 @@ pub fn Outlet(cx: Scope) -> impl IntoView {
leptos::leptos_dom::DynChild::new_with_id(id, move || outlet.get())
}
/// Displays the child route nested in a parent route, allowing you to control exactly where
/// that child route is displayed. Renders nothing if there is no nested child.
///
/// ## Animations
/// The router uses CSS classes for animations, and transitions to the next specified class in order when
/// the `animationend` event fires. Each property takes a `&'static str` that can contain a class or classes
/// to be added at certain points. These CSS classes must have associated animations.
/// - `outro`: added when route is being unmounted
/// - `start`: added when route is first created
/// - `intro`: added after `start` has completed (if defined), and the route is being mounted
/// - `finally`: added after the `intro` animation is complete
///
/// Each of these properties is optional, and the router will transition to the next correct state
/// whenever an `animationend` event fires.
#[component]
pub fn AnimatedOutlet(
cx: Scope,
/// CSS class added when route is being unmounted
#[prop(optional)]
outro: Option<&'static str>,
/// CSS class added when route is being unmounted, in a “back” navigation
#[prop(optional)]
outro_back: Option<&'static str>,
/// CSS class added when route is first created
#[prop(optional)]
start: Option<&'static str>,
/// CSS class added while the route is being mounted
#[prop(optional)]
intro: Option<&'static str>,
/// CSS class added while the route is being mounted, in a “back” navigation
#[prop(optional)]
intro_back: Option<&'static str>,
/// CSS class added after other animations have completed.
#[prop(optional)]
finally: Option<&'static str>,
) -> impl IntoView {
let route = use_route(cx);
let is_showing = Rc::new(Cell::new(None::<(usize, Scope)>));
let (outlet, set_outlet) = create_signal(cx, None::<View>);
let animation = Animation {
outro,
start,
intro,
finally,
outro_back,
intro_back,
};
let (animation_state, set_animation_state) =
create_signal(cx, AnimationState::Finally);
let trigger_animation = create_rw_signal(cx, ());
let is_back = use_is_back_navigation(cx);
let animation_and_outlet = create_memo(cx, {
move |prev: Option<&(AnimationState, View)>| {
let animation_state = animation_state.get();
let next_outlet = outlet.get().unwrap_or_default();
trigger_animation.track();
match prev {
None => (animation_state, next_outlet),
Some((prev_state, prev_outlet)) => {
let (next_state, can_advance) = animation
.next_state(prev_state, is_back.get_untracked());
if can_advance {
(next_state, next_outlet)
} else {
(next_state, prev_outlet.to_owned())
}
}
}
}
});
let current_animation =
create_memo(cx, move |_| animation_and_outlet.get().0);
let current_outlet = create_memo(cx, move |_| animation_and_outlet.get().1);
create_isomorphic_effect(cx, move |_| {
match (route.child(cx), &is_showing.get()) {
(None, prev) => {
if let Some(prev_scope) = prev.map(|(_, scope)| scope) {
prev_scope.dispose();
}
set_outlet.set(None);
}
(Some(child), Some((is_showing_val, _)))
if child.id() == *is_showing_val =>
{
// do nothing: we don't need to rerender the component, because it's the same
trigger_animation.set(());
}
(Some(child), prev) => {
if let Some(prev_scope) = prev.map(|(_, scope)| scope) {
prev_scope.dispose();
}
_ = cx.child_scope(|child_cx| {
provide_context(child_cx, child.clone());
set_outlet
.set(Some(child.outlet(child_cx).into_view(child_cx)));
is_showing.set(Some((child.id(), child_cx)));
});
}
}
});
let class = move || match current_animation.get() {
AnimationState::Outro => outro.unwrap_or_default(),
AnimationState::Start => start.unwrap_or_default(),
AnimationState::Intro => intro.unwrap_or_default(),
AnimationState::Finally => finally.unwrap_or_default(),
AnimationState::OutroBack => outro_back.unwrap_or_default(),
AnimationState::IntroBack => intro_back.unwrap_or_default(),
};
let animationend = move |ev: AnimationEvent| {
ev.stop_propagation();
let current = current_animation.get();
set_animation_state.update(|current_state| {
let (next, _) =
animation.next_state(&current, is_back.get_untracked());
*current_state = next;
});
};
view! { cx,
<div class=class on:animationend=animationend>
{move || current_outlet.get()}
</div>
}
}

View file

@ -54,6 +54,7 @@ pub(crate) struct RouterContextInner {
referrers: Rc<RefCell<Vec<LocationChange>>>,
state: ReadSignal<State>,
set_state: WriteSignal<State>,
pub(crate) is_back: RwSignal<bool>,
}
impl std::fmt::Debug for RouterContextInner {
@ -110,6 +111,7 @@ impl RouterContext {
replace: true,
scroll: false,
state: State(None),
back: false,
});
}
}
@ -162,6 +164,7 @@ impl RouterContext {
state,
set_state,
possible_routes: Default::default(),
is_back: create_rw_signal(cx, false),
});
// handle all click events on anchor tags
@ -200,6 +203,7 @@ impl RouterContextInner {
self: Rc<Self>,
to: &str,
options: &NavigateOptions,
back: bool,
) -> Result<(), NavigationError> {
let cx = self.cx;
let this = Rc::clone(&self);
@ -227,6 +231,7 @@ impl RouterContextInner {
replace: options.replace,
scroll: options.scroll,
state: self.state.get(),
back,
});
}
let len = self.referrers.borrow().len();
@ -250,6 +255,7 @@ impl RouterContextInner {
replace: false,
scroll: true,
state,
back,
})
}
}
@ -359,8 +365,9 @@ impl RouterContextInner {
scroll: !a.has_attribute("noscroll"),
state: State(state),
},
false,
) {
log::error!("{e:#?}");
leptos::error!("{e:#?}");
}
}
}

View file

@ -1,9 +1,10 @@
use crate::{
animation::*,
matching::{
expand_optionals, get_route_matches, join_paths, Branch, Matcher,
RouteDefinition, RouteMatch,
},
RouteContext, RouterContext,
use_is_back_navigation, RouteContext, RouterContext,
};
use leptos::{leptos_dom::HydrationCtx, *};
use std::{
@ -19,7 +20,9 @@ use std::{
#[component]
pub fn Routes(
cx: Scope,
#[prop(optional)] base: Option<String>,
/// Base path relative at which the routes are mounted.
#[prop(optional)]
base: Option<String>,
children: Children,
) -> impl IntoView {
let router = use_context::<RouterContext>(cx)
@ -46,7 +49,6 @@ pub fn Routes(
})
.cloned()
.collect::<Vec<_>>();
create_branches(
&children,
&base.unwrap_or_default(),
@ -59,20 +61,195 @@ pub fn Routes(
*context.0.borrow_mut() = branches.clone();
}
let next_route = router.pathname();
let current_route = next_route;
let root_equal = Rc::new(Cell::new(true));
let route_states =
route_states(cx, &router, branches, current_route, &root_equal);
let id = HydrationCtx::id();
let root = root_route(cx, base_route, route_states, root_equal);
leptos::leptos_dom::DynChild::new_with_id(id, move || root.get())
.into_view(cx)
}
/// Contains route definitions and manages the actual routing process, with animated transitions
/// between routes.
///
/// You should locate the `<AnimatedRoutes/>` component wherever on the page you want the routes to appear.
///
/// ## Animations
/// The router uses CSS classes for animations, and transitions to the next specified class in order when
/// the `animationend` event fires. Each property takes a `&'static str` that can contain a class or classes
/// to be added at certain points. These CSS classes must have associated animations.
/// - `outro`: added when route is being unmounted
/// - `start`: added when route is first created
/// - `intro`: added after `start` has completed (if defined), and the route is being mounted
/// - `finally`: added after the `intro` animation is complete
///
/// Each of these properties is optional, and the router will transition to the next correct state
/// whenever an `animationend` event fires.
#[component]
pub fn AnimatedRoutes(
cx: Scope,
/// Base path relative at which the routes are mounted.
#[prop(optional)]
base: Option<String>,
/// CSS class added when route is being unmounted
#[prop(optional)]
outro: Option<&'static str>,
/// CSS class added when route is being unmounted, in a “back” navigation
#[prop(optional)]
outro_back: Option<&'static str>,
/// CSS class added when route is first created
#[prop(optional)]
start: Option<&'static str>,
/// CSS class added while the route is being mounted
#[prop(optional)]
intro: Option<&'static str>,
/// CSS class added while the route is being mounted, in a “back” navigation
#[prop(optional)]
intro_back: Option<&'static str>,
/// CSS class added after other animations have completed.
#[prop(optional)]
finally: Option<&'static str>,
children: Children,
) -> impl IntoView {
let router = use_context::<RouterContext>(cx)
.expect("<Routes/> component should be nested within a <Router/>.");
let base_route = router.base();
let mut branches = Vec::new();
let frag = children(cx);
let children = frag
.as_children()
.iter()
.filter_map(|child| {
let def = child
.as_transparent()
.and_then(|t| t.downcast_ref::<RouteDefinition>());
if def.is_none() {
warn!(
"[NOTE] The <Routes/> component should include *only* \
<Route/>or <ProtectedRoute/> components, or some \
#[component(transparent)] that returns a RouteDefinition."
);
}
def
})
.cloned()
.collect::<Vec<_>>();
create_branches(
&children,
&base.unwrap_or_default(),
&mut Vec::new(),
&mut branches,
);
#[cfg(feature = "ssr")]
if let Some(context) = use_context::<crate::PossibleBranchContext>(cx) {
*context.0.borrow_mut() = branches.clone();
}
let animation = Animation {
outro,
start,
intro,
finally,
outro_back,
intro_back,
};
let is_back = use_is_back_navigation(cx);
let (animation_state, set_animation_state) =
create_signal(cx, AnimationState::Finally);
let next_route = router.pathname();
let animation_and_route = create_memo(cx, {
let branches = branches.clone();
move |prev: Option<&(AnimationState, String)>| {
let animation_state = animation_state.get();
let next_route = next_route.get();
let prev_matches = prev
.map(|(_, r)| r)
.cloned()
.map(|prev| get_route_matches(&branches, prev));
let matches = get_route_matches(&branches, next_route.clone());
let same_route = prev_matches
.and_then(|p| p.get(0).as_ref().map(|r| r.route.key.clone()))
== matches.get(0).as_ref().map(|r| r.route.key.clone());
if same_route {
(animation_state, next_route)
} else {
match prev {
None => (animation_state, next_route),
Some((prev_state, prev_route)) => {
let (next_state, can_advance) = animation
.next_state(prev_state, is_back.get_untracked());
if can_advance {
(next_state, next_route)
} else {
(next_state, prev_route.to_owned())
}
}
}
}
}
});
let current_animation =
create_memo(cx, move |_| animation_and_route.get().0);
let current_route = create_memo(cx, move |_| animation_and_route.get().1);
let root_equal = Rc::new(Cell::new(true));
let route_states =
route_states(cx, &router, branches, current_route, &root_equal);
let root = root_route(cx, base_route, route_states, root_equal);
html::div(cx)
.attr(
"class",
(cx, move || match current_animation.get() {
AnimationState::Outro => outro.unwrap_or_default(),
AnimationState::Start => start.unwrap_or_default(),
AnimationState::Intro => intro.unwrap_or_default(),
AnimationState::Finally => finally.unwrap_or_default(),
AnimationState::OutroBack => outro_back.unwrap_or_default(),
AnimationState::IntroBack => intro_back.unwrap_or_default(),
}),
)
.on(leptos::ev::animationend, move |_| {
let current = current_animation.get();
set_animation_state.update(|current_state| {
let (next, _) =
animation.next_state(&current, is_back.get_untracked());
*current_state = next;
})
})
.child(move || root.get())
.into_view(cx)
}
fn route_states(
cx: Scope,
router: &RouterContext,
branches: Vec<Branch>,
current_route: Memo<String>,
root_equal: &Rc<Cell<bool>>,
) -> Memo<RouterState> {
// whenever path changes, update matches
let matches = create_memo(cx, {
let router = router.clone();
move |_| get_route_matches(branches.clone(), router.pathname().get())
let matches = create_memo(cx, move |_| {
get_route_matches(&branches, current_route.get())
});
// iterate over the new matches, reusing old routes when they are the same
// and replacing them with new routes when they differ
let next: Rc<RefCell<Vec<RouteContext>>> = Default::default();
let router = Rc::clone(&router.inner);
let root_equal = Rc::new(Cell::new(true));
let route_states: Memo<RouterState> = create_memo(cx, {
let root_equal = Rc::clone(&root_equal);
create_memo(cx, {
let root_equal = Rc::clone(root_equal);
move |prev: Option<&RouterState>| {
root_equal.set(true);
next.borrow_mut().clear();
@ -114,12 +291,13 @@ pub fn Routes(
}
let next = next.clone();
let router = Rc::clone(&router.inner);
let next = next.clone();
let next_ctx = RouteContext::new(
cx,
&RouterContext { inner: router },
&RouterContext {
inner: Rc::clone(&router),
},
{
let next = next.clone();
move |cx| {
@ -174,12 +352,18 @@ pub fn Routes(
}
}
}
});
})
}
// show the root route
let id = HydrationCtx::id();
fn root_route(
cx: Scope,
base_route: RouteContext,
route_states: Memo<RouterState>,
root_equal: Rc<Cell<bool>>,
) -> Memo<Option<View>> {
let root_cx = RefCell::new(None);
let root = create_memo(cx, move |prev| {
create_memo(cx, move |prev| {
provide_context(cx, route_states);
route_states.with(|state| {
if state.routes.borrow().is_empty() {
@ -209,9 +393,7 @@ pub fn Routes(
}
}
})
});
leptos::leptos_dom::DynChild::new_with_id(id, move || root.get())
})
}
#[derive(Clone, Debug, PartialEq)]

View file

@ -12,7 +12,7 @@ pub fn create_location(
path.with(|path| match Url::try_from(path.as_str()) {
Ok(url) => url,
Err(e) => {
log::error!("[Leptos Router] Invalid path {path}\n\n{e:?}");
leptos::error!("[Leptos Router] Invalid path {path}\n\n{e:?}");
prev.cloned().unwrap()
}
})
@ -62,6 +62,8 @@ pub struct LocationChange {
pub scroll: bool,
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that will be added during navigation.
pub state: State,
/// Whether the navigation is a “back” navigation.
pub back: bool,
}
impl Default for LocationChange {
@ -71,6 +73,7 @@ impl Default for LocationChange {
replace: true,
scroll: true,
state: Default::default(),
back: false,
}
}
}

View file

@ -35,7 +35,7 @@ pub trait History {
pub struct BrowserIntegration {}
impl BrowserIntegration {
fn current() -> LocationChange {
fn current(back: bool) -> LocationChange {
let loc = leptos_dom::helpers::location();
LocationChange {
value: loc.pathname().unwrap_or_default()
@ -44,6 +44,7 @@ impl BrowserIntegration {
replace: true,
scroll: true,
state: State(None), // TODO
back,
}
}
}
@ -52,12 +53,17 @@ impl History for BrowserIntegration {
fn location(&self, cx: Scope) -> ReadSignal<LocationChange> {
use crate::{NavigateOptions, RouterContext};
let (location, set_location) = create_signal(cx, Self::current());
let (location, set_location) = create_signal(cx, Self::current(false));
leptos::window_event_listener("popstate", move |_| {
let router = use_context::<RouterContext>(cx);
if let Some(router) = router {
let change = Self::current();
let is_back = router.inner.is_back;
let change = Self::current(true);
is_back.set(true);
request_animation_frame(move || {
is_back.set(false);
});
if let Err(e) = router.inner.navigate_from_route(
&change.value,
&NavigateOptions {
@ -66,12 +72,13 @@ impl History for BrowserIntegration {
scroll: change.scroll,
state: change.state,
},
true,
) {
log::error!("{e:#?}");
leptos::error!("{e:#?}");
}
set_location.set(Self::current());
set_location.set(Self::current(true));
} else {
log::warn!("RouterContext not found");
leptos::warn!("RouterContext not found");
}
});
@ -165,6 +172,7 @@ impl History for ServerIntegration {
replace: false,
scroll: true,
state: State(None),
back: false,
},
)
.0

View file

@ -81,6 +81,12 @@ pub fn use_navigate(
) -> impl Fn(&str, NavigateOptions) -> Result<(), NavigationError> {
let router = use_router(cx);
move |to, options| {
Rc::clone(&router.inner).navigate_from_route(to, &options)
Rc::clone(&router.inner).navigate_from_route(to, &options, false)
}
}
/// Returns a signal that tells you whether you are currently navigating backwards.
pub(crate) fn use_is_back_navigation(cx: Scope) -> ReadSignal<bool> {
let router = use_router(cx);
router.inner.is_back.read_only()
}

View file

@ -187,6 +187,7 @@
#![cfg_attr(not(feature = "stable"), feature(negative_impls))]
#![cfg_attr(not(feature = "stable"), feature(type_name_of_val))]
mod animation;
mod components;
#[cfg(any(feature = "ssr", doc))]
mod extract_routes;

View file

@ -16,7 +16,7 @@ pub(crate) struct RouteMatch {
}
pub(crate) fn get_route_matches(
branches: Vec<Branch>,
branches: &Vec<Branch>,
location: String,
) -> Vec<RouteMatch> {
for branch in branches {