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

Quickly animate scroll when calling ui.scroll_to_cursor etc #4119

Merged
merged 10 commits into from
Mar 4, 2024
Merged
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
97 changes: 79 additions & 18 deletions crates/egui/src/containers/scroll_area.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@

use crate::*;

#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
struct ScrollTarget {
animation_time_span: (f64, f64),
target_offset: f32,
}

#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct State {
/// Positive offset means scrolling down/right
pub offset: Vec2,

/// If set, quickly but smoothly scroll to this target offset.
offset_target: [Option<ScrollTarget>; 2],

/// Were the scroll bars visible last frame?
show_scroll: Vec2b,

Expand All @@ -35,6 +45,7 @@ impl Default for State {
fn default() -> Self {
Self {
offset: Vec2::ZERO,
offset_target: Default::default(),
show_scroll: Vec2b::FALSE,
content_is_too_large: Vec2b::FALSE,
scroll_bar_interaction: Vec2b::FALSE,
Expand Down Expand Up @@ -559,25 +570,56 @@ impl ScrollArea {
state.vel[d] = input.pointer.velocity()[d];
});
state.scroll_stuck_to_end[d] = false;
state.offset_target[d] = None;
} else {
state.vel[d] = 0.0;
}
}
} else {
// Kinetic scrolling
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.
let dt = ui.input(|i| i.unstable_dt);

let friction = friction_coeff * dt;
if friction > state.vel.length() || state.vel.length() < stop_speed {
state.vel = Vec2::ZERO;
} else {
state.vel -= friction * state.vel.normalized();
// Offset has an inverted coordinate system compared to
// the velocity, so we subtract it instead of adding it
state.offset -= state.vel * dt;
ctx.request_repaint();
for d in 0..2 {
let dt = ui.input(|i| i.stable_dt).at_most(0.1);

if let Some(scroll_target) = state.offset_target[d] {
state.vel[d] = 0.0;

if (state.offset[d] - scroll_target.target_offset).abs() < 1.0 {
// Arrived
state.offset[d] = scroll_target.target_offset;
state.offset_target[d] = None;
} else {
// Move towards target
let t = emath::interpolation_factor(
scroll_target.animation_time_span,
ui.input(|i| i.time),
dt,
emath::ease_in_ease_out,
);
if t < 1.0 {
state.offset[d] =
emath::lerp(state.offset[d]..=scroll_target.target_offset, t);
ctx.request_repaint();
} else {
// Arrived
state.offset[d] = scroll_target.target_offset;
state.offset_target[d] = None;
}
}
} else {
// Kinetic scrolling
let stop_speed = 20.0; // Pixels per second.
let friction_coeff = 1000.0; // Pixels per second squared.

let friction = friction_coeff * dt;
if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed {
state.vel[d] = 0.0;
} else {
state.vel[d] -= friction * state.vel[d].signum();
// Offset has an inverted coordinate system compared to
// the velocity, so we subtract it instead of adding it
state.offset[d] -= state.vel[d] * dt;
ctx.request_repaint();
}
}
}
}
}
Expand Down Expand Up @@ -716,11 +758,11 @@ impl Prepared {
let scroll_target = content_ui
.ctx()
.frame_state_mut(|state| state.scroll_target[d].take());
if let Some((scroll, align)) = scroll_target {
if let Some((target_range, align)) = scroll_target {
let min = content_ui.min_rect().min[d];
let clip_rect = content_ui.clip_rect();
let visible_range = min..=min + clip_rect.size()[d];
let (start, end) = (scroll.min, scroll.max);
let (start, end) = (target_range.min, target_range.max);
let clip_start = clip_rect.min[d];
let clip_end = clip_rect.max[d];
let mut spacing = ui.spacing().item_spacing[d];
Expand All @@ -729,7 +771,7 @@ impl Prepared {
let center_factor = align.to_factor();

let offset =
lerp(scroll, center_factor) - lerp(visible_range, center_factor);
lerp(target_range, center_factor) - lerp(visible_range, center_factor);

// Depending on the alignment we need to add or subtract the spacing
spacing *= remap(center_factor, 0.0..=1.0, -1.0..=1.0);
Expand All @@ -745,7 +787,24 @@ impl Prepared {
};

if delta != 0.0 {
state.offset[d] += delta;
let target_offset = state.offset[d] + delta;

if let Some(animation) = &mut state.offset_target[d] {
// For instance: the user is continuously calling `ui.scroll_to_cursor`,
// so we don't want to reset the animation, but perhaps update the target:
animation.target_offset = target_offset;
} else {
// The further we scroll, the more time we take.
// TODO(emilk): let users configure this in `Style`.
let now = ui.input(|i| i.time);
let points_per_second = 1000.0;
let animation_duration =
(delta.abs() / points_per_second).clamp(0.1, 0.3);
state.offset_target[d] = Some(ScrollTarget {
animation_time_span: (now, now + animation_duration as f64),
target_offset,
});
}
ui.ctx().request_repaint();
}
}
Expand Down Expand Up @@ -808,6 +867,7 @@ impl Prepared {
});

state.scroll_stuck_to_end[d] = false;
state.offset_target[d] = None;
}
}
}
Expand Down Expand Up @@ -952,6 +1012,7 @@ impl Prepared {

// some manual action taken, scroll not stuck
state.scroll_stuck_to_end[d] = false;
state.offset_target[d] = None;
} else {
state.scroll_start_offset_from_top_left[d] = None;
}
Expand Down
13 changes: 10 additions & 3 deletions crates/egui/src/input_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ impl InputState {

let mut unprocessed_scroll_delta = self.unprocessed_scroll_delta;

let smooth_scroll_delta;
let mut smooth_scroll_delta = Vec2::ZERO;

{
// Mouse wheels often go very large steps.
Expand All @@ -233,8 +233,15 @@ impl InputState {
let dt = stable_dt.at_most(0.1);
let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO: parameterize

smooth_scroll_delta = t * unprocessed_scroll_delta;
unprocessed_scroll_delta -= smooth_scroll_delta;
for d in 0..2 {
if unprocessed_scroll_delta[d].abs() < 1.0 {
smooth_scroll_delta[d] = unprocessed_scroll_delta[d];
unprocessed_scroll_delta[d] = 0.0;
} else {
smooth_scroll_delta[d] = t * unprocessed_scroll_delta[d];
unprocessed_scroll_delta[d] -= smooth_scroll_delta[d];
}
}
}

let mut modifiers = new.modifiers;
Expand Down
1 change: 1 addition & 0 deletions crates/egui/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ impl Response {

/// Adjust the scroll position until this UI becomes visible.
///
/// If `align` is [`Align::TOP`] it means "put the top of the rect at the top of the scroll area", etc.
/// If `align` is `None`, it'll scroll enough to bring the UI into view.
///
/// See also: [`Ui::scroll_to_cursor`], [`Ui::scroll_to_rect`]. [`Ui::scroll_with_delta`].
Expand Down
2 changes: 2 additions & 0 deletions crates/egui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,7 @@ impl Ui {

/// Adjust the scroll position of any parent [`ScrollArea`] so that the given [`Rect`] becomes visible.
///
/// If `align` is [`Align::TOP`] it means "put the top of the rect at the top of the scroll area", etc.
/// If `align` is `None`, it'll scroll enough to bring the cursor into view.
///
/// See also: [`Response::scroll_to_me`], [`Ui::scroll_to_cursor`]. [`Ui::scroll_with_delta`]..
Expand All @@ -1028,6 +1029,7 @@ impl Ui {

/// Adjust the scroll position of any parent [`ScrollArea`] so that the cursor (where the next widget goes) becomes visible.
///
/// If `align` is [`Align::TOP`] it means "put the top of the rect at the top of the scroll area", etc.
/// If `align` is not provided, it'll scroll enough to bring the cursor into view.
///
/// See also: [`Response::scroll_to_me`], [`Ui::scroll_to_rect`]. [`Ui::scroll_with_delta`].
Expand Down
6 changes: 4 additions & 2 deletions crates/egui_demo_lib/src/demo/scrolling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ impl super::View for ScrollTo {
fn ui(&mut self, ui: &mut Ui) {
ui.label("This shows how you can scroll to a specific item or pixel offset");

let num_items = 500;

let mut track_item = false;
let mut go_to_scroll_offset = false;
let mut scroll_top = false;
Expand All @@ -260,7 +262,7 @@ impl super::View for ScrollTo {
ui.horizontal(|ui| {
ui.label("Scroll to a specific item index:");
track_item |= ui
.add(Slider::new(&mut self.track_item, 1..=50).text("Track Item"))
.add(Slider::new(&mut self.track_item, 1..=num_items).text("Track Item"))
.dragged();
});

Expand Down Expand Up @@ -304,7 +306,7 @@ impl super::View for ScrollTo {
ui.scroll_to_cursor(Some(Align::TOP));
}
ui.vertical(|ui| {
for item in 1..=50 {
for item in 1..=num_items {
if track_item && item == self.track_item {
let response =
ui.colored_label(Color32::YELLOW, format!("This is item {item}"));
Expand Down
46 changes: 46 additions & 0 deletions crates/emath/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,52 @@ pub fn exponential_smooth_factor(
1.0 - (1.0 - reach_this_fraction).powf(dt / in_this_many_seconds)
}

/// If you have a value animating over time,
/// how much towards its target do you need to move it this frame?
///
/// You only need to store the start time and target value in order to animate using this function.
///
/// ``` rs
/// struct Animation {
/// current_value: f32,
///
/// animation_time_span: (f64, f64),
/// target_value: f32,
/// }
///
/// impl Animation {
/// fn update(&mut self, now: f64, dt: f32) {
/// let t = interpolation_factor(self.animation_time_span, now, dt, ease_in_ease_out);
/// self.current_value = emath::lerp(self.current_value..=self.target_value, t);
/// }
/// }
/// ```
pub fn interpolation_factor(
(start_time, end_time): (f64, f64),
current_time: f64,
dt: f32,
easing: impl Fn(f32) -> f32,
) -> f32 {
let animation_duration = (end_time - start_time) as f32;
let prev_time = current_time - dt as f64;
let prev_t = easing((prev_time - start_time) as f32 / animation_duration);
let end_t = easing((current_time - start_time) as f32 / animation_duration);
if end_t < 1.0 {
(end_t - prev_t) / (1.0 - prev_t)
} else {
1.0
}
}

/// Ease in, ease out.
///
/// `f(0) = 0, f'(0) = 0, f(1) = 1, f'(1) = 0`.
#[inline]
pub fn ease_in_ease_out(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
(3.0 * t * t - 2.0 * t * t * t).clamp(0.0, 1.0)
}

// ----------------------------------------------------------------------------

/// An assert that is only active when `emath` is compiled with the `extra_asserts` feature
Expand Down
Loading