Quickly animate scroll when calling `ui.scroll_to_cursor` etc (#4119)
Uses ease-in-ease-out interpolation, with a time between 0.1s and 0.3s, depending on the distance needed to scroll. 
This commit is contained in:
parent
e29022efc4
commit
18eeb01f57
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
use crate::*;
|
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)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
#[cfg_attr(feature = "serde", serde(default))]
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
|
@ -9,6 +16,9 @@ pub struct State {
|
||||||
/// Positive offset means scrolling down/right
|
/// Positive offset means scrolling down/right
|
||||||
pub offset: Vec2,
|
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?
|
/// Were the scroll bars visible last frame?
|
||||||
show_scroll: Vec2b,
|
show_scroll: Vec2b,
|
||||||
|
|
||||||
|
|
@ -35,6 +45,7 @@ impl Default for State {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
offset: Vec2::ZERO,
|
offset: Vec2::ZERO,
|
||||||
|
offset_target: Default::default(),
|
||||||
show_scroll: Vec2b::FALSE,
|
show_scroll: Vec2b::FALSE,
|
||||||
content_is_too_large: Vec2b::FALSE,
|
content_is_too_large: Vec2b::FALSE,
|
||||||
scroll_bar_interaction: Vec2b::FALSE,
|
scroll_bar_interaction: Vec2b::FALSE,
|
||||||
|
|
@ -559,25 +570,56 @@ impl ScrollArea {
|
||||||
state.vel[d] = input.pointer.velocity()[d];
|
state.vel[d] = input.pointer.velocity()[d];
|
||||||
});
|
});
|
||||||
state.scroll_stuck_to_end[d] = false;
|
state.scroll_stuck_to_end[d] = false;
|
||||||
|
state.offset_target[d] = None;
|
||||||
} else {
|
} else {
|
||||||
state.vel[d] = 0.0;
|
state.vel[d] = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Kinetic scrolling
|
for d in 0..2 {
|
||||||
let stop_speed = 20.0; // Pixels per second.
|
let dt = ui.input(|i| i.stable_dt).at_most(0.1);
|
||||||
let friction_coeff = 1000.0; // Pixels per second squared.
|
|
||||||
let dt = ui.input(|i| i.unstable_dt);
|
|
||||||
|
|
||||||
let friction = friction_coeff * dt;
|
if let Some(scroll_target) = state.offset_target[d] {
|
||||||
if friction > state.vel.length() || state.vel.length() < stop_speed {
|
state.vel[d] = 0.0;
|
||||||
state.vel = Vec2::ZERO;
|
|
||||||
} else {
|
if (state.offset[d] - scroll_target.target_offset).abs() < 1.0 {
|
||||||
state.vel -= friction * state.vel.normalized();
|
// Arrived
|
||||||
// Offset has an inverted coordinate system compared to
|
state.offset[d] = scroll_target.target_offset;
|
||||||
// the velocity, so we subtract it instead of adding it
|
state.offset_target[d] = None;
|
||||||
state.offset -= state.vel * dt;
|
} else {
|
||||||
ctx.request_repaint();
|
// 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
|
let scroll_target = content_ui
|
||||||
.ctx()
|
.ctx()
|
||||||
.frame_state_mut(|state| state.scroll_target[d].take());
|
.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 min = content_ui.min_rect().min[d];
|
||||||
let clip_rect = content_ui.clip_rect();
|
let clip_rect = content_ui.clip_rect();
|
||||||
let visible_range = min..=min + clip_rect.size()[d];
|
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_start = clip_rect.min[d];
|
||||||
let clip_end = clip_rect.max[d];
|
let clip_end = clip_rect.max[d];
|
||||||
let mut spacing = ui.spacing().item_spacing[d];
|
let mut spacing = ui.spacing().item_spacing[d];
|
||||||
|
|
@ -729,7 +771,7 @@ impl Prepared {
|
||||||
let center_factor = align.to_factor();
|
let center_factor = align.to_factor();
|
||||||
|
|
||||||
let offset =
|
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
|
// Depending on the alignment we need to add or subtract the spacing
|
||||||
spacing *= remap(center_factor, 0.0..=1.0, -1.0..=1.0);
|
spacing *= remap(center_factor, 0.0..=1.0, -1.0..=1.0);
|
||||||
|
|
@ -745,7 +787,24 @@ impl Prepared {
|
||||||
};
|
};
|
||||||
|
|
||||||
if delta != 0.0 {
|
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();
|
ui.ctx().request_repaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -808,6 +867,7 @@ impl Prepared {
|
||||||
});
|
});
|
||||||
|
|
||||||
state.scroll_stuck_to_end[d] = false;
|
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
|
// some manual action taken, scroll not stuck
|
||||||
state.scroll_stuck_to_end[d] = false;
|
state.scroll_stuck_to_end[d] = false;
|
||||||
|
state.offset_target[d] = None;
|
||||||
} else {
|
} else {
|
||||||
state.scroll_start_offset_from_top_left[d] = None;
|
state.scroll_start_offset_from_top_left[d] = None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ impl InputState {
|
||||||
|
|
||||||
let mut unprocessed_scroll_delta = self.unprocessed_scroll_delta;
|
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.
|
// Mouse wheels often go very large steps.
|
||||||
|
|
@ -233,8 +233,15 @@ impl InputState {
|
||||||
let dt = stable_dt.at_most(0.1);
|
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
|
let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO: parameterize
|
||||||
|
|
||||||
smooth_scroll_delta = t * unprocessed_scroll_delta;
|
for d in 0..2 {
|
||||||
unprocessed_scroll_delta -= smooth_scroll_delta;
|
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;
|
let mut modifiers = new.modifiers;
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,7 @@ impl Response {
|
||||||
|
|
||||||
/// Adjust the scroll position until this UI becomes visible.
|
/// 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.
|
/// 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`].
|
/// See also: [`Ui::scroll_to_cursor`], [`Ui::scroll_to_rect`]. [`Ui::scroll_with_delta`].
|
||||||
|
|
|
||||||
|
|
@ -1002,6 +1002,7 @@ impl Ui {
|
||||||
|
|
||||||
/// Adjust the scroll position of any parent [`ScrollArea`] so that the given [`Rect`] becomes visible.
|
/// 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.
|
/// 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`]..
|
/// 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.
|
/// 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.
|
/// 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`].
|
/// See also: [`Response::scroll_to_me`], [`Ui::scroll_to_rect`]. [`Ui::scroll_with_delta`].
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,8 @@ impl super::View for ScrollTo {
|
||||||
fn ui(&mut self, ui: &mut Ui) {
|
fn ui(&mut self, ui: &mut Ui) {
|
||||||
ui.label("This shows how you can scroll to a specific item or pixel offset");
|
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 track_item = false;
|
||||||
let mut go_to_scroll_offset = false;
|
let mut go_to_scroll_offset = false;
|
||||||
let mut scroll_top = false;
|
let mut scroll_top = false;
|
||||||
|
|
@ -260,7 +262,7 @@ impl super::View for ScrollTo {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Scroll to a specific item index:");
|
ui.label("Scroll to a specific item index:");
|
||||||
track_item |= ui
|
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();
|
.dragged();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -304,7 +306,7 @@ impl super::View for ScrollTo {
|
||||||
ui.scroll_to_cursor(Some(Align::TOP));
|
ui.scroll_to_cursor(Some(Align::TOP));
|
||||||
}
|
}
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
for item in 1..=50 {
|
for item in 1..=num_items {
|
||||||
if track_item && item == self.track_item {
|
if track_item && item == self.track_item {
|
||||||
let response =
|
let response =
|
||||||
ui.colored_label(Color32::YELLOW, format!("This is item {item}"));
|
ui.colored_label(Color32::YELLOW, format!("This is item {item}"));
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,52 @@ pub fn exponential_smooth_factor(
|
||||||
1.0 - (1.0 - reach_this_fraction).powf(dt / in_this_many_seconds)
|
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
|
/// An assert that is only active when `emath` is compiled with the `extra_asserts` feature
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue