diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index d18cd802..27128f67 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -2,6 +2,13 @@ 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))] @@ -9,6 +16,9 @@ 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; 2], + /// Were the scroll bars visible last frame? show_scroll: Vec2b, @@ -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, @@ -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); + for d in 0..2 { + let dt = ui.input(|i| i.stable_dt).at_most(0.1); - 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(); + 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(); + } + } } } } @@ -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]; @@ -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); @@ -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(); } } @@ -808,6 +867,7 @@ impl Prepared { }); state.scroll_stuck_to_end[d] = false; + state.offset_target[d] = None; } } } @@ -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; } diff --git a/crates/egui/src/input_state.rs b/crates/egui/src/input_state.rs index 1b1417d1..ed29282e 100644 --- a/crates/egui/src/input_state.rs +++ b/crates/egui/src/input_state.rs @@ -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. @@ -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; diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index e63def16..2be55242 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -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`]. diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index b8216011..4e28ece4 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -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`].. @@ -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`]. diff --git a/crates/egui_demo_lib/src/demo/scrolling.rs b/crates/egui_demo_lib/src/demo/scrolling.rs index 30855f77..0530f2fe 100644 --- a/crates/egui_demo_lib/src/demo/scrolling.rs +++ b/crates/egui_demo_lib/src/demo/scrolling.rs @@ -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; @@ -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(); }); @@ -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}")); diff --git a/crates/emath/src/lib.rs b/crates/emath/src/lib.rs index d7709166..911fb52a 100644 --- a/crates/emath/src/lib.rs +++ b/crates/emath/src/lib.rs @@ -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