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).
This commit is contained in:
Emil Ernerfeldt 2024-06-06 13:09:52 +02:00 committed by GitHub
parent 3d632cd333
commit 29b12e1760
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 307 additions and 28 deletions

View File

@ -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();

View File

@ -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)
}
}

View File

@ -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<InnerResponse<R>> {
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<InnerResponse<R>> {
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<InnerResponse<R>> {
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<R> {
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<InnerResponse<R>> {
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<InnerResponse<R>> {
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<InnerResponse<R>> {
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<R> {
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))

View File

@ -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,
);

View File

@ -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;
}

View File

@ -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.

View File

@ -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();

230
crates/emath/src/easing.rs Normal file
View File

@ -0,0 +1,230 @@
//! Easing functions for animations.
//!
//! Contains most easing functions from <https://easings.net/>.
//!
//! All functions take a value in `[0, 1]` and return a value in `[0, 1]`.
//!
//! Derived from <https://github.com/warrenm/AHEasing/blob/master/AHEasing/easing.c>.
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
}
/// <https://easings.net/#easeInQuad>
///
/// Modeled after the parabola `y = x^2`
#[inline]
pub fn quadratic_in(t: f32) -> f32 {
t * t
}
/// <https://easings.net/#easeOutQuad>
///
/// Same as `1.0 - quadratic_in(1.0 - t)`.
#[inline]
pub fn quadratic_out(t: f32) -> f32 {
-(t * (t - 2.))
}
/// <https://easings.net/#easeInOutQuad>
#[inline]
pub fn quadratic_in_out(t: f32) -> f32 {
if t < 0.5 {
2. * t * t
} else {
(-2. * t * t) + (4. * t) - 1.
}
}
/// <https://easings.net/#easeInCubic>
///
/// Modeled after the parabola `y = x^3`
#[inline]
pub fn cubic_in(t: f32) -> f32 {
t * t * t
}
/// <https://easings.net/#easeOutCubic>
#[inline]
pub fn cubic_out(t: f32) -> f32 {
let f = t - 1.;
f * f * f + 1.
}
/// <https://easings.net/#easeInOutCubic>
#[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.
}
}
/// <https://easings.net/#easeInSine>
///
/// Modeled after quarter-cycle of sine wave
#[inline]
pub fn sin_in(t: f32) -> f32 {
((t - 1.) * 2. * PI).sin() + 1.
}
/// <https://easings.net/#easeOuSine>
///
/// Modeled after quarter-cycle of sine wave (different phase)
#[inline]
pub fn sin_out(t: f32) -> f32 {
(t * 2. * PI).sin()
}
/// <https://easings.net/#easeInOutSine>
///
/// Modeled after half sine wave
#[inline]
pub fn sin_in_out(t: f32) -> f32 {
0.5 * (1. - (t * PI).cos())
}
/// <https://easings.net/#easeInCirc>
///
/// Modeled after shifted quadrant IV of unit circle
#[inline]
pub fn circular_in(t: f32) -> f32 {
1. - (1. - t * t).sqrt()
}
/// <https://easings.net/#easeOutCirc>
///
/// Modeled after shifted quadrant II of unit circle
#[inline]
pub fn circular_out(t: f32) -> f32 {
(2. - t).sqrt() * t
}
/// <https://easings.net/#easeInOutCirc>
#[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.)
}
}
/// <https://easings.net/#easeInExpo>
///
/// 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.))
}
}
/// <https://easings.net/#easeOutExpo>
///
/// 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)
}
}
/// <https://easings.net/#easeInOutExpo>
///
/// 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.
}
}
/// <https://easings.net/#easeInBack>
#[inline]
pub fn back_in(t: f32) -> f32 {
t * t * t - t * (t * PI).sin()
}
/// <https://easings.net/#easeOutBack>
#[inline]
pub fn back_out(t: f32) -> f32 {
let f = 1. - t;
1. - (f * f * f - f * (f * PI).sin())
}
/// <https://easings.net/#easeInOutBack>
#[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
}
}
/// <https://easings.net/#easeInBounce>
///
/// Each bounce is modelled as a parabola.
#[inline]
pub fn bounce_in(t: f32) -> f32 {
1. - bounce_out(1. - t)
}
/// <https://easings.net/#easeOutBounce>
///
/// 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
}
}
/// <https://easings.net/#easeInOutBounce>
///
/// 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
}
}

View File

@ -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;