Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gpui: Add transition animations #25602

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions crates/gpui/examples/animation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use anyhow::Result;
use gpui::{
black, bounce, div, ease_in_out, percentage, prelude::*, px, rgb, size, svg, Animation,
AnimationExt as _, App, Application, AssetSource, Bounds, Context, SharedString,
Transformation, Window, WindowBounds, WindowOptions,
Transformation, TransitionAnimation, Window, WindowBounds, WindowOptions,
};

struct Assets {}
Expand Down Expand Up @@ -33,10 +33,12 @@ const ARROW_CIRCLE_SVG: &str = concat!(
"/examples/image/arrow_circle.svg"
);

struct AnimationExample {}
struct AnimationExample {
hovered: bool,
}

impl Render for AnimationExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().flex().flex_col().size_full().justify_around().child(
div().flex().flex_row().w_full().justify_around().child(
div()
Expand All @@ -54,16 +56,32 @@ impl Render for AnimationExample {
.size_8()
.path(ARROW_CIRCLE_SVG)
.text_color(black())
.id("svg")
.on_mouse_move(|_, _, _| {})
Copy link
Contributor Author

@someone13574 someone13574 Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated question: Does anyone know why I need another event handler for the on_hover to receive events? Is this intended?

.on_hover(cx.listener(|this, hovered, _, cx| {
this.hovered = *hovered;
cx.notify();
}))
.with_animation(
"image_circle",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(bounce(ease_in_out)),
|svg, delta| {
svg.with_transformation(Transformation::rotate(percentage(
delta,
)))
svg.map_element(|svg| {
svg.with_transformation(Transformation::rotate(percentage(
delta,
)))
})
},
)
.with_transition(
self.hovered,
"hover-transition",
TransitionAnimation::new(Duration::from_millis(1000))
.backward(Some(Duration::from_millis(500)))
.with_easing(ease_in_out),
|svg, _forward, delta| svg.size(px(32.0 + delta * 32.0)),
),
),
),
Expand All @@ -85,7 +103,7 @@ fn main() {
};
cx.open_window(options, |_, cx| {
cx.activate(false);
cx.new(|_| AnimationExample {})
cx.new(|_| AnimationExample { hovered: false })
})
.unwrap();
});
Expand Down
225 changes: 224 additions & 1 deletion crates/gpui/src/elements/animation.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use std::time::{Duration, Instant};

use crate::{AnyElement, App, Element, ElementId, GlobalElementId, IntoElement, Window};
use crate::{
AnyElement, App, Element, ElementId, GlobalElementId, InteractiveElement, Interactivity,
IntoElement, StyleRefinement, Styled, Window,
};

pub use easing::*;

Expand Down Expand Up @@ -41,6 +44,55 @@ impl Animation {
}
}

/// An animation which can be applied to an element when transitioning between states
pub struct TransitionAnimation {
/// The amount of time this animation should run for when transitioning from false to true.
/// When `None`, this transition isn't animated.
pub forward_duration: Option<Duration>,

/// The amount of time this animation should run for when transitioning from true to false.
/// When `None`, this transition isn't animated.
pub backward_duration: Option<Duration>,

/// A function that takes a delta between 0 and 1 and returns a new delta
/// between 0 and 1 based on the given easing function.
pub easing: Box<dyn Fn(f32) -> f32>,
}

impl TransitionAnimation {
/// Create a new transition animation with the given duration.
/// By default the animation will run in both directions and will use a linear easing function.
pub fn new(duration: Duration) -> Self {
Self {
forward_duration: Some(duration),
backward_duration: Some(duration),
easing: Box::new(linear),
}
}

/// Sets he amount of time this animation should run for when transitioning from false to true.
/// When `None`, this transition isn't animated.
pub fn forward(mut self, duration: Option<Duration>) -> Self {
self.forward_duration = duration;
self
}

/// Sets he amount of time this animation should run for when transitioning from true to false.
/// When `None`, this transition isn't animated.
pub fn backward(mut self, duration: Option<Duration>) -> Self {
self.backward_duration = duration;
self
}

/// Set the easing function to use for this animation.
/// The easing function will take a time delta between 0 and 1 and return a new delta
/// between 0 and 1
pub fn with_easing(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
self.easing = Box::new(easing);
self
}
}

/// An extension trait for adding the animation wrapper to both Elements and Components
pub trait AnimationExt {
/// Render this component or element with an animation
Expand All @@ -60,6 +112,26 @@ pub trait AnimationExt {
animation,
}
}

/// Render this component or element with an animation between states
fn with_transition(
self,
condition: bool,
id: impl Into<ElementId>,
animation: TransitionAnimation,
animator: impl Fn(Self, bool, f32) -> Self + 'static,
) -> TransitionElement<Self>
where
Self: Sized,
{
TransitionElement {
id: id.into(),
condition,
element: Some(self),
animation,
animator: Box::new(animator),
}
}
}

impl<E> AnimationExt for E {}
Expand All @@ -81,6 +153,24 @@ impl<E> AnimationElement<E> {
}
}

impl<E> Styled for AnimationElement<E>
where
E: Styled,
{
fn style(&mut self) -> &mut StyleRefinement {
self.element.as_mut().unwrap().style()
}
}

impl<E> InteractiveElement for AnimationElement<E>
where
E: InteractiveElement,
{
fn interactivity(&mut self) -> &mut Interactivity {
self.element.as_mut().unwrap().interactivity()
}
}

impl<E: IntoElement + 'static> IntoElement for AnimationElement<E> {
type Element = AnimationElement<E>;

Expand Down Expand Up @@ -165,6 +255,139 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
}
}

/// A GPUI element that applies an animation to another element when transitioning between states
pub struct TransitionElement<E> {
id: ElementId,
condition: bool,
element: Option<E>,
animation: TransitionAnimation,
animator: Box<dyn Fn(E, bool, f32) -> E + 'static>,
}

impl<E> TransitionElement<E> {
/// Returns a new [`TransitionElement<E>`] after applying the given function
/// to the element being animated.
pub fn map_element(mut self, f: impl FnOnce(E) -> E) -> TransitionElement<E> {
self.element = self.element.map(f);
self
}
}

impl<E> Styled for TransitionElement<E>
where
E: Styled,
{
fn style(&mut self) -> &mut StyleRefinement {
self.element.as_mut().unwrap().style()
}
}

impl<E> InteractiveElement for TransitionElement<E>
where
E: InteractiveElement,
{
fn interactivity(&mut self) -> &mut Interactivity {
self.element.as_mut().unwrap().interactivity()
}
}

impl<E: IntoElement + 'static> IntoElement for TransitionElement<E> {
type Element = Self;

fn into_element(self) -> Self::Element {
self
}
}

impl<E: IntoElement + 'static> Element for TransitionElement<E> {
type RequestLayoutState = AnyElement;

type PrepaintState = ();

fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}

fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
window: &mut Window,
cx: &mut App,
) -> (crate::LayoutId, Self::RequestLayoutState) {
window.with_element_state(id.unwrap(), |state, window| {
let (mut start_time, mut start_delta, animating_forward) =
state.unwrap_or_else(|| (Instant::now(), 1.0, self.condition));

let raw_delta = match (
animating_forward,
self.animation.forward_duration,
self.animation.backward_duration,
) {
(true, Some(duration), _) | (false, _, Some(duration)) => {
start_time.elapsed().as_secs_f32() / duration.as_secs_f32() + start_delta
}
_ => 1.0,
};

let mut done = raw_delta > 1.0;
let raw_delta = raw_delta.min(1.0);

let delta = (self.animation.easing)(if animating_forward {
raw_delta
} else {
1.0 - raw_delta
});

if self.condition != animating_forward {
start_delta = 1.0 - raw_delta;
start_time = Instant::now();
done = false;
}

// Animate element
let element = self.element.take().expect("should only be called once");
let mut element = if !done || animating_forward {
(self.animator)(element, self.condition, delta)
} else {
element
}
.into_any_element();

if !done {
window.request_animation_frame();
}

(
(element.request_layout(window, cx), element),
(start_time, start_delta, self.condition),
)
})
}

fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_bounds: crate::Bounds<crate::Pixels>,
element: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
element.prepaint(window, cx);
}

fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_bounds: crate::Bounds<crate::Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
element.paint(window, cx);
}
}

mod easing {
use std::f32::consts::PI;

Expand Down
8 changes: 8 additions & 0 deletions crates/gpui/src/elements/div.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2727,6 +2727,14 @@ pub struct Stateful<E> {
pub(crate) element: E,
}

impl<E> Stateful<E> {
/// Applies a function to the inner element
pub fn map_element(mut self, f: impl FnOnce(E) -> E) -> Self {
self.element = f(self.element);
self
}
}

impl<E> Styled for Stateful<E>
where
E: Styled,
Expand Down
Loading