From 29b12e1760393c3d1eb221796ac313189d33c1c3 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Thu, 6 Jun 2024 13:09:52 +0200 Subject: [PATCH] Easing functions (#4630) This adds most of the "standard" easing functions from https://easings.net/ to `emath::easing`, and adds helpers in `egui` for using them. In particular there is now `ctx.animate_bool_with_easing` and `ctx.animate_bool_responsive`, that uses a cubic easing function. All animations in egui now uses cubic ease-out, for a more responsive feeling (fast at the start, slower towards the end). --- crates/egui/src/containers/area.rs | 1 + .../egui/src/containers/collapsing_header.rs | 2 +- crates/egui/src/containers/panel.rs | 30 ++- crates/egui/src/containers/scroll_area.rs | 12 +- crates/egui/src/containers/window.rs | 6 +- crates/egui/src/context.rs | 49 +++- .../egui_demo_lib/src/demo/toggle_switch.rs | 4 +- crates/emath/src/easing.rs | 230 ++++++++++++++++++ crates/emath/src/lib.rs | 1 + 9 files changed, 307 insertions(+), 28 deletions(-) create mode 100644 crates/emath/src/easing.rs diff --git a/crates/egui/src/containers/area.rs b/crates/egui/src/containers/area.rs index cf27ddb0..f3a66cdd 100644 --- a/crates/egui/src/containers/area.rs +++ b/crates/egui/src/containers/area.rs @@ -519,6 +519,7 @@ impl Prepared { let age = ctx.input(|i| (i.time - self.state.last_became_visible_at) as f32 + i.predicted_dt); let opacity = crate::remap_clamp(age, 0.0..=ctx.style().animation_time, 0.0..=1.0); + let opacity = emath::easing::cubic_out(opacity); // slow fade-out = quick fade-in ui.multiply_opacity(opacity); if opacity < 1.0 { ctx.request_repaint(); diff --git a/crates/egui/src/containers/collapsing_header.rs b/crates/egui/src/containers/collapsing_header.rs index a4c59add..388293d1 100644 --- a/crates/egui/src/containers/collapsing_header.rs +++ b/crates/egui/src/containers/collapsing_header.rs @@ -72,7 +72,7 @@ impl CollapsingState { if ctx.memory(|mem| mem.everything_is_visible()) { 1.0 } else { - ctx.animate_bool(self.id, self.state.open) + ctx.animate_bool_responsive(self.id, self.state.open) } } diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index 09ff67fb..ada35244 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -17,6 +17,10 @@ use crate::*; +fn animate_expansion(ctx: &Context, id: Id, is_expanded: bool) -> f32 { + ctx.animate_bool_responsive(id, is_expanded) +} + /// State regarding panels. #[derive(Clone, Copy, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -387,7 +391,7 @@ impl SidePanel { is_expanded: bool, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let how_expanded = ctx.animate_bool(self.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); if 0.0 == how_expanded { None @@ -420,9 +424,7 @@ impl SidePanel { is_expanded: bool, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let how_expanded = ui - .ctx() - .animate_bool(self.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); if 0.0 == how_expanded { None @@ -455,7 +457,7 @@ impl SidePanel { expanded_panel: Self, add_contents: impl FnOnce(&mut Ui, f32) -> R, ) -> Option> { - let how_expanded = ctx.animate_bool(expanded_panel.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); if 0.0 == how_expanded { Some(collapsed_panel.show(ctx, |ui| add_contents(ui, how_expanded))) @@ -487,9 +489,8 @@ impl SidePanel { expanded_panel: Self, add_contents: impl FnOnce(&mut Ui, f32) -> R, ) -> InnerResponse { - let how_expanded = ui - .ctx() - .animate_bool(expanded_panel.id.with("animation"), is_expanded); + let how_expanded = + animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); if 0.0 == how_expanded { collapsed_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) @@ -875,7 +876,7 @@ impl TopBottomPanel { is_expanded: bool, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let how_expanded = ctx.animate_bool(self.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ctx, self.id.with("animation"), is_expanded); if 0.0 == how_expanded { None @@ -910,9 +911,7 @@ impl TopBottomPanel { is_expanded: bool, add_contents: impl FnOnce(&mut Ui) -> R, ) -> Option> { - let how_expanded = ui - .ctx() - .animate_bool(self.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ui.ctx(), self.id.with("animation"), is_expanded); if 0.0 == how_expanded { None @@ -947,7 +946,7 @@ impl TopBottomPanel { expanded_panel: Self, add_contents: impl FnOnce(&mut Ui, f32) -> R, ) -> Option> { - let how_expanded = ctx.animate_bool(expanded_panel.id.with("animation"), is_expanded); + let how_expanded = animate_expansion(ctx, expanded_panel.id.with("animation"), is_expanded); if 0.0 == how_expanded { Some(collapsed_panel.show(ctx, |ui| add_contents(ui, how_expanded))) @@ -985,9 +984,8 @@ impl TopBottomPanel { expanded_panel: Self, add_contents: impl FnOnce(&mut Ui, f32) -> R, ) -> InnerResponse { - let how_expanded = ui - .ctx() - .animate_bool(expanded_panel.id.with("animation"), is_expanded); + let how_expanded = + animate_expansion(ui.ctx(), expanded_panel.id.with("animation"), is_expanded); if 0.0 == how_expanded { collapsed_panel.show_inside(ui, |ui| add_contents(ui, how_expanded)) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 8bf0c563..bf9e510f 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -514,8 +514,8 @@ impl ScrollArea { }; let show_bars_factor = Vec2::new( - ctx.animate_bool(id.with("h"), show_bars[0]), - ctx.animate_bool(id.with("v"), show_bars[1]), + ctx.animate_bool_responsive(id.with("h"), show_bars[0]), + ctx.animate_bool_responsive(id.with("v"), show_bars[1]), ); let current_bar_use = show_bars_factor.yx() * ui.spacing().scroll.allocated_width(); @@ -928,10 +928,10 @@ impl Prepared { // Avoid frame delay; start showing scroll bar right away: if show_scroll_this_frame[0] && show_bars_factor.x <= 0.0 { - show_bars_factor.x = ui.ctx().animate_bool(id.with("h"), true); + show_bars_factor.x = ui.ctx().animate_bool_responsive(id.with("h"), true); } if show_scroll_this_frame[1] && show_bars_factor.y <= 0.0 { - show_bars_factor.y = ui.ctx().animate_bool(id.with("v"), true); + show_bars_factor.y = ui.ctx().animate_bool_responsive(id.with("v"), true); } let scroll_style = ui.spacing().scroll; @@ -970,7 +970,7 @@ impl Prepared { || state.scroll_bar_interaction[d]; let is_hovering_bar_area_t = ui .ctx() - .animate_bool(id.with((d, "bar_hover")), is_hovering_bar_area); + .animate_bool_responsive(id.with((d, "bar_hover")), is_hovering_bar_area); let width = show_factor * lerp( scroll_style.floating_width..=scroll_style.bar_width, @@ -1125,7 +1125,7 @@ impl Prepared { if response.hovered() || response.dragged() { scroll_style.interact_handle_opacity } else { - let is_hovering_outer_rect_t = ui.ctx().animate_bool( + let is_hovering_outer_rect_t = ui.ctx().animate_bool_responsive( id.with((d, "is_hovering_outer_rect")), is_hovering_outer_rect, ); diff --git a/crates/egui/src/containers/window.rs b/crates/egui/src/containers/window.rs index b0d1d432..f2d5c33a 100644 --- a/crates/egui/src/containers/window.rs +++ b/crates/egui/src/containers/window.rs @@ -438,7 +438,11 @@ impl<'open> Window<'open> { let is_explicitly_closed = matches!(open, Some(false)); let is_open = !is_explicitly_closed || ctx.memory(|mem| mem.everything_is_visible()); - let opacity = ctx.animate_bool(area.id.with("fade-out"), is_open); + let opacity = ctx.animate_bool_with_easing( + area.id.with("fade-out"), + is_open, + emath::easing::cubic_out, + ); if opacity <= 0.0 { return None; } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 9354a9fe..4e031b21 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -2442,12 +2442,51 @@ impl Context { #[track_caller] // To track repaint cause pub fn animate_bool(&self, id: Id, value: bool) -> f32 { let animation_time = self.style().animation_time; - self.animate_bool_with_time(id, value, animation_time) + self.animate_bool_with_time_and_easing(id, value, animation_time, emath::easing::linear) + } + + /// Like [`Self::animate_bool`], but uses an easing function that makes the value move + /// quickly in the beginning and slow down towards the end. + /// + /// The exact easing function may come to change in future versions of egui. + #[track_caller] // To track repaint cause + pub fn animate_bool_responsive(&self, id: Id, value: bool) -> f32 { + self.animate_bool_with_easing(id, value, emath::easing::cubic_out) + } + + /// Like [`Self::animate_bool`] but allows you to control the easing function. + #[track_caller] // To track repaint cause + pub fn animate_bool_with_easing(&self, id: Id, value: bool, easing: fn(f32) -> f32) -> f32 { + let animation_time = self.style().animation_time; + self.animate_bool_with_time_and_easing(id, value, animation_time, easing) } /// Like [`Self::animate_bool`] but allows you to control the animation time. #[track_caller] // To track repaint cause pub fn animate_bool_with_time(&self, id: Id, target_value: bool, animation_time: f32) -> f32 { + self.animate_bool_with_time_and_easing( + id, + target_value, + animation_time, + emath::easing::linear, + ) + } + + /// Like [`Self::animate_bool`] but allows you to control the animation time and easing function. + /// + /// Use e.g. [`emath::easing::quadratic_out`] + /// for a responsive start and a slow end. + /// + /// The easing function flips when `target_value` is `false`, + /// so that when going back towards 0.0, we get + #[track_caller] // To track repaint cause + pub fn animate_bool_with_time_and_easing( + &self, + id: Id, + target_value: bool, + animation_time: f32, + easing: fn(f32) -> f32, + ) -> f32 { let animated_value = self.write(|ctx| { ctx.animation_manager.animate_bool( &ctx.viewports.entry(ctx.viewport_id()).or_default().input, @@ -2456,11 +2495,17 @@ impl Context { target_value, ) }); + let animation_in_progress = 0.0 < animated_value && animated_value < 1.0; if animation_in_progress { self.request_repaint(); } - animated_value + + if target_value { + easing(animated_value) + } else { + 1.0 - easing(1.0 - animated_value) + } } /// Smoothly animate an `f32` value. diff --git a/crates/egui_demo_lib/src/demo/toggle_switch.rs b/crates/egui_demo_lib/src/demo/toggle_switch.rs index 8a2d5336..fbc42d60 100644 --- a/crates/egui_demo_lib/src/demo/toggle_switch.rs +++ b/crates/egui_demo_lib/src/demo/toggle_switch.rs @@ -46,7 +46,7 @@ pub fn toggle_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { // Let's ask for a simple animation from egui. // egui keeps track of changes in the boolean associated with the id and // returns an animated value in the 0-1 range for how much "on" we are. - let how_on = ui.ctx().animate_bool(response.id, *on); + let how_on = ui.ctx().animate_bool_responsive(response.id, *on); // We will follow the current style by asking // "how should something that is being interacted with be painted?". // This will, for instance, give us different colors when the widget is hovered or clicked. @@ -80,7 +80,7 @@ fn toggle_ui_compact(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, "")); if ui.is_rect_visible(rect) { - let how_on = ui.ctx().animate_bool(response.id, *on); + let how_on = ui.ctx().animate_bool_responsive(response.id, *on); let visuals = ui.style().interact_selectable(&response, *on); let rect = rect.expand(visuals.expansion); let radius = 0.5 * rect.height(); diff --git a/crates/emath/src/easing.rs b/crates/emath/src/easing.rs new file mode 100644 index 00000000..352c451c --- /dev/null +++ b/crates/emath/src/easing.rs @@ -0,0 +1,230 @@ +//! Easing functions for animations. +//! +//! Contains most easing functions from . +//! +//! All functions take a value in `[0, 1]` and return a value in `[0, 1]`. +//! +//! Derived from . +use std::f32::consts::PI; + +#[inline] +fn powf(base: f32, exp: f32) -> f32 { + base.powf(exp) +} + +/// No easing, just `y = x` +#[inline] +pub fn linear(t: f32) -> f32 { + t +} + +/// +/// +/// Modeled after the parabola `y = x^2` +#[inline] +pub fn quadratic_in(t: f32) -> f32 { + t * t +} + +/// +/// +/// Same as `1.0 - quadratic_in(1.0 - t)`. +#[inline] +pub fn quadratic_out(t: f32) -> f32 { + -(t * (t - 2.)) +} + +/// +#[inline] +pub fn quadratic_in_out(t: f32) -> f32 { + if t < 0.5 { + 2. * t * t + } else { + (-2. * t * t) + (4. * t) - 1. + } +} + +/// +/// +/// Modeled after the parabola `y = x^3` +#[inline] +pub fn cubic_in(t: f32) -> f32 { + t * t * t +} + +/// +#[inline] +pub fn cubic_out(t: f32) -> f32 { + let f = t - 1.; + f * f * f + 1. +} + +/// +#[inline] +pub fn cubic_in_out(t: f32) -> f32 { + if t < 0.5 { + 4. * t * t * t + } else { + let f = (2. * t) - 2.; + 0.5 * f * f * f + 1. + } +} + +/// +/// +/// Modeled after quarter-cycle of sine wave +#[inline] +pub fn sin_in(t: f32) -> f32 { + ((t - 1.) * 2. * PI).sin() + 1. +} + +/// +/// +/// Modeled after quarter-cycle of sine wave (different phase) +#[inline] +pub fn sin_out(t: f32) -> f32 { + (t * 2. * PI).sin() +} + +/// +/// +/// Modeled after half sine wave +#[inline] +pub fn sin_in_out(t: f32) -> f32 { + 0.5 * (1. - (t * PI).cos()) +} + +/// +/// +/// Modeled after shifted quadrant IV of unit circle +#[inline] +pub fn circular_in(t: f32) -> f32 { + 1. - (1. - t * t).sqrt() +} + +/// +/// +/// Modeled after shifted quadrant II of unit circle +#[inline] +pub fn circular_out(t: f32) -> f32 { + (2. - t).sqrt() * t +} + +/// +#[inline] +pub fn circular_in_out(t: f32) -> f32 { + if t < 0.5 { + 0.5 * (1. - (1. - 4. * t * t).sqrt()) + } else { + 0.5 * ((-(2. * t - 3.) * (2. * t - 1.)).sqrt() + 1.) + } +} + +/// +/// +/// There is a small discontinuity at 0. +#[inline] +pub fn exponential_in(t: f32) -> f32 { + if t == 0. { + t + } else { + powf(2.0, 10. * (t - 1.)) + } +} + +/// +/// +/// There is a small discontinuity at 1. +#[inline] +pub fn exponential_out(t: f32) -> f32 { + if t == 1. { + t + } else { + 1. - powf(2.0, -10. * t) + } +} + +/// +/// +/// There is a small discontinuity at 0 and 1. +#[inline] +pub fn exponential_in_out(t: f32) -> f32 { + if t == 0. || t == 1. { + t + } else if t < 0.5 { + 0.5 * powf(2.0, 20. * t - 10.) + } else { + 0.5 * powf(2.0, -20. * t + 10.) + 1. + } +} + +/// +#[inline] +pub fn back_in(t: f32) -> f32 { + t * t * t - t * (t * PI).sin() +} + +/// +#[inline] +pub fn back_out(t: f32) -> f32 { + let f = 1. - t; + 1. - (f * f * f - f * (f * PI).sin()) +} + +/// +#[inline] +pub fn back_in_out(t: f32) -> f32 { + if t < 0.5 { + let f = 2. * t; + 0.5 * (f * f * f - f * (f * PI).sin()) + } else { + let f = 1. - (2. * t - 1.); + 0.5 * (1. - (f * f * f - f * (f * PI).sin())) + 0.5 + } +} + +/// +/// +/// Each bounce is modelled as a parabola. +#[inline] +pub fn bounce_in(t: f32) -> f32 { + 1. - bounce_out(1. - t) +} + +/// +/// +/// Each bounce is modelled as a parabola. +#[inline] +pub fn bounce_out(t: f32) -> f32 { + if t < 4. / 11. { + const T2: f32 = 121. / 16.; + T2 * t * t + } else if t < 8. / 11. { + const T2: f32 = 363. / 40.; + const T1: f32 = -99. / 10.; + const T0: f32 = 17. / 5.; + T2 * t * t + T1 * t + T0 + } else if t < 9. / 10. { + const T2: f32 = 4356. / 361.; + const T1: f32 = -35442. / 1805.; + const T0: f32 = 16061. / 1805.; + T2 * t * t + T1 * t + T0 + } else { + const T2: f32 = 54. / 5.; + const T1: f32 = -513. / 25.; + const T0: f32 = 268. / 25.; + T2 * t * t + T1 * t + T0 + } +} + +/// +/// +/// Each bounce is modelled as a parabola. +#[inline] +pub fn bounce_in_out(t: f32) -> f32 { + if t < 0.5 { + 0.5 * bounce_in(t * 2.) + } else { + 0.5 * bounce_out(t * 2. - 1.) + 0.5 + } +} diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs index 6ad48bd2..5abf59e2 100644 --- a/crates/emath/src/lib.rs +++ b/crates/emath/src/lib.rs @@ -26,6 +26,7 @@ use std::ops::{Add, Div, Mul, RangeInclusive, Sub}; // ---------------------------------------------------------------------------- pub mod align; +pub mod easing; mod history; mod numeric; mod ordered_float;