From e13fe04fc50ef3e389c8119b9b4c5b41504067a1 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Wed, 31 Jul 2024 09:54:29 +0200 Subject: [PATCH] Make `scroll_to_*` animations configurable (#4305) * Closes #4295 I based this on #4303, I'll rebase once that one gets merged. --- crates/egui/src/containers/scroll_area.rs | 34 +++++---- crates/egui/src/frame_state.rs | 32 +++++++-- crates/egui/src/response.rs | 26 +++++-- crates/egui/src/style.rs | 88 +++++++++++++++++++++++ crates/egui/src/ui.rs | 40 +++++++++-- 5 files changed, 191 insertions(+), 29 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index e564fbe1e4a..3f0e3a2df35 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -4,7 +4,7 @@ use crate::*; #[derive(Clone, Copy, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -struct ScrollTarget { +struct ScrollingToTarget { animation_time_span: (f64, f64), target_offset: f32, } @@ -17,7 +17,7 @@ pub struct State { pub offset: Vec2, /// If set, quickly but smoothly scroll to this target offset. - offset_target: [Option; 2], + offset_target: [Option; 2], /// Were the scroll bars visible last frame? show_scroll: Vec2b, @@ -799,7 +799,8 @@ impl Prepared { for d in 0..2 { // FrameState::scroll_delta is inverted from the way we apply the delta, so we need to negate it. - let mut delta = -scroll_delta[d]; + let mut delta = -scroll_delta.0[d]; + let mut animation = scroll_delta.1; // We always take both scroll targets regardless of which scroll axes are enabled. This // is to avoid them leaking to other scroll areas. @@ -808,20 +809,25 @@ impl Prepared { .frame_state_mut(|state| state.scroll_target[d].take()); if scroll_enabled[d] { - delta += if let Some((target_range, align)) = scroll_target { + if let Some(target) = scroll_target { + let frame_state::ScrollTarget { + range, + align, + animation: animation_update, + } = 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) = (target_range.min, target_range.max); + let (start, end) = (range.min, range.max); let clip_start = clip_rect.min[d]; let clip_end = clip_rect.max[d]; let mut spacing = ui.spacing().item_spacing[d]; - if let Some(align) = align { + let delta_update = if let Some(align) = align { let center_factor = align.to_factor(); let offset = - lerp(target_range, center_factor) - lerp(visible_range, center_factor); + lerp(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); @@ -834,9 +840,10 @@ impl Prepared { } else { // Ui is already in view, no need to adjust scroll. 0.0 - } - } else { - 0.0 + }; + + delta += delta_update; + animation = animation_update; }; if delta != 0.0 { @@ -850,11 +857,10 @@ impl Prepared { 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 { + let animation_duration = (delta.abs() / animation.points_per_second) + .clamp(animation.duration.min, animation.duration.max); + state.offset_target[d] = Some(ScrollingToTarget { animation_time_span: (now, now + animation_duration as f64), target_offset, }); diff --git a/crates/egui/src/frame_state.rs b/crates/egui/src/frame_state.rs index 04286a909eb..70c558fc498 100644 --- a/crates/egui/src/frame_state.rs +++ b/crates/egui/src/frame_state.rs @@ -40,6 +40,30 @@ pub struct PerLayerState { pub widget_with_tooltip: Option, } +#[derive(Clone, Debug)] +pub struct ScrollTarget { + // The range that the scroll area should scroll to. + pub range: Rangef, + + /// How should we align the rect within the visible area? + /// 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. + pub align: Option, + + /// How should the scroll be animated? + pub animation: style::ScrollAnimation, +} + +impl ScrollTarget { + pub fn new(range: Rangef, align: Option, animation: style::ScrollAnimation) -> Self { + Self { + range, + align, + animation, + } + } +} + #[cfg(feature = "accesskit")] #[derive(Clone)] pub struct AccessKitFrameState { @@ -172,7 +196,7 @@ pub struct FrameState { pub used_by_panels: Rect, /// The current scroll area should scroll to this range (horizontal, vertical). - pub scroll_target: [Option<(Rangef, Option)>; 2], + pub scroll_target: [Option; 2], /// The current scroll area should scroll by this much. /// @@ -183,7 +207,7 @@ pub struct FrameState { /// /// A positive Y-value indicates the content is being moved down, /// as when swiping down on a touch-screen or track-pad with natural scrolling. - pub scroll_delta: Vec2, + pub scroll_delta: (Vec2, style::ScrollAnimation), #[cfg(feature = "accesskit")] pub accesskit_state: Option, @@ -206,7 +230,7 @@ impl Default for FrameState { unused_rect: Rect::NAN, used_by_panels: Rect::NAN, scroll_target: [None, None], - scroll_delta: Vec2::default(), + scroll_delta: (Vec2::default(), style::ScrollAnimation::none()), #[cfg(feature = "accesskit")] accesskit_state: None, highlight_next_frame: Default::default(), @@ -246,7 +270,7 @@ impl FrameState { *unused_rect = screen_rect; *used_by_panels = Rect::NOTHING; *scroll_target = [None, None]; - *scroll_delta = Vec2::default(); + *scroll_delta = Default::default(); #[cfg(debug_assertions)] { diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index cb2ef5467f1..d6a9a8a0c7f 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -2,10 +2,9 @@ use std::{any::Any, sync::Arc}; use crate::{ emath::{Align, Pos2, Rect, Vec2}, - menu, AreaState, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, Ui, WidgetRect, - WidgetText, + frame_state, menu, AreaState, Context, CursorIcon, Id, LayerId, Order, PointerButton, Sense, + Ui, WidgetRect, WidgetText, }; - // ---------------------------------------------------------------------------- /// The result of adding a widget to a [`Ui`]. @@ -888,9 +887,26 @@ impl Response { /// # }); /// ``` pub fn scroll_to_me(&self, align: Option) { + self.scroll_to_me_animation(align, self.ctx.style().scroll_animation); + } + + /// Like [`Self::scroll_to_me`], but allows you to specify the [`crate::style::ScrollAnimation`]. + pub fn scroll_to_me_animation( + &self, + align: Option, + animation: crate::style::ScrollAnimation, + ) { self.ctx.frame_state_mut(|state| { - state.scroll_target[0] = Some((self.rect.x_range(), align)); - state.scroll_target[1] = Some((self.rect.y_range(), align)); + state.scroll_target[0] = Some(frame_state::ScrollTarget::new( + self.rect.x_range(), + align, + animation, + )); + state.scroll_target[1] = Some(frame_state::ScrollTarget::new( + self.rect.y_range(), + align, + animation, + )); }); } diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index b85d849a27f..2ed9f03f7c9 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -280,6 +280,9 @@ pub struct Style { /// If true and scrolling is enabled for only one direction, allow horizontal scrolling without pressing shift pub always_scroll_the_only_direction: bool, + + /// The animation that should be used when scrolling a [`crate::ScrollArea`] using e.g. [Ui::scroll_to_rect]. + pub scroll_animation: ScrollAnimation, } #[test] @@ -692,6 +695,88 @@ impl ScrollStyle { // ---------------------------------------------------------------------------- +/// Scroll animation configuration, used when programmatically scrolling somewhere (e.g. with `[crate::Ui::scroll_to_cursor]`) +/// The animation duration is calculated based on the distance to be scrolled via `[ScrollAnimation::points_per_second]` +/// and can be clamped to a min / max duration via `[ScrollAnimation::duration]`. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct ScrollAnimation { + /// With what speed should we scroll? (Default: 1000.0) + pub points_per_second: f32, + + /// The min / max scroll duration. + pub duration: Rangef, +} + +impl Default for ScrollAnimation { + fn default() -> Self { + Self { + points_per_second: 1000.0, + duration: Rangef::new(0.1, 0.3), + } + } +} + +impl ScrollAnimation { + /// New scroll animation + pub fn new(points_per_second: f32, duration: Rangef) -> Self { + Self { + points_per_second, + duration, + } + } + + /// No animation, scroll instantly. + pub fn none() -> Self { + Self { + points_per_second: f32::INFINITY, + duration: Rangef::new(0.0, 0.0), + } + } + + /// Scroll with a fixed duration, regardless of distance. + pub fn duration(t: f32) -> Self { + Self { + points_per_second: f32::INFINITY, + duration: Rangef::new(t, t), + } + } + + pub fn ui(&mut self, ui: &mut crate::Ui) { + crate::Grid::new("scroll_animation").show(ui, |ui| { + ui.label("Scroll animation:"); + ui.add( + DragValue::new(&mut self.points_per_second) + .speed(100.0) + .range(0.0..=5000.0), + ); + ui.label("points/second"); + ui.end_row(); + + ui.label("Min duration:"); + ui.add( + DragValue::new(&mut self.duration.min) + .speed(0.01) + .range(0.0..=self.duration.max), + ); + ui.label("seconds"); + ui.end_row(); + + ui.label("Max duration:"); + ui.add( + DragValue::new(&mut self.duration.max) + .speed(0.01) + .range(0.0..=1.0), + ); + ui.label("seconds"); + ui.end_row(); + }); + } +} + +// ---------------------------------------------------------------------------- + /// How and when interaction happens. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -1129,6 +1214,7 @@ impl Default for Style { explanation_tooltips: false, url_in_tooltip: false, always_scroll_the_only_direction: false, + scroll_animation: ScrollAnimation::default(), } } } @@ -1425,6 +1511,7 @@ impl Style { explanation_tooltips, url_in_tooltip, always_scroll_the_only_direction, + scroll_animation, } = self; visuals.light_dark_radio_buttons(ui); @@ -1488,6 +1575,7 @@ impl Style { ui.collapsing("📏 Spacing", |ui| spacing.ui(ui)); ui.collapsing("☝ Interaction", |ui| interaction.ui(ui)); ui.collapsing("🎨 Visuals", |ui| visuals.ui(ui)); + ui.collapsing("🔄 Scroll Animation", |ui| scroll_animation.ui(ui)); #[cfg(debug_assertions)] ui.collapsing("🐛 Debug", |ui| debug.ui(ui)); diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 18f6bff4399..b50dadbbff6 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -9,7 +9,6 @@ use crate::{ containers::*, ecolor::*, epaint::text::Fonts, layout::*, menu::MenuState, placer::Placer, util::IdTypeMap, widgets::*, *, }; - // ---------------------------------------------------------------------------- /// This is what you use to place widgets. @@ -1216,10 +1215,22 @@ impl Ui { /// # }); /// ``` pub fn scroll_to_rect(&self, rect: Rect, align: Option) { + self.scroll_to_rect_animation(rect, align, self.style.scroll_animation); + } + + /// Same as [`Self::scroll_to_rect`], but allows you to specify the [`style::ScrollAnimation`]. + pub fn scroll_to_rect_animation( + &self, + rect: Rect, + align: Option, + animation: style::ScrollAnimation, + ) { for d in 0..2 { let range = Rangef::new(rect.min[d], rect.max[d]); - self.ctx() - .frame_state_mut(|state| state.scroll_target[d] = Some((range, align))); + self.ctx().frame_state_mut(|state| { + state.scroll_target[d] = + Some(frame_state::ScrollTarget::new(range, align, animation)); + }); } } @@ -1246,11 +1257,22 @@ impl Ui { /// # }); /// ``` pub fn scroll_to_cursor(&self, align: Option) { + self.scroll_to_cursor_animation(align, self.style.scroll_animation); + } + + /// Same as [`Self::scroll_to_cursor`], but allows you to specify the [`style::ScrollAnimation`]. + pub fn scroll_to_cursor_animation( + &self, + align: Option, + animation: style::ScrollAnimation, + ) { let target = self.next_widget_position(); for d in 0..2 { let target = Rangef::point(target[d]); - self.ctx() - .frame_state_mut(|state| state.scroll_target[d] = Some((target, align))); + self.ctx().frame_state_mut(|state| { + state.scroll_target[d] = + Some(frame_state::ScrollTarget::new(target, align, animation)); + }); } } @@ -1284,8 +1306,14 @@ impl Ui { /// # }); /// ``` pub fn scroll_with_delta(&self, delta: Vec2) { + self.scroll_with_delta_animation(delta, self.style.scroll_animation); + } + + /// Same as [`Self::scroll_with_delta`], but allows you to specify the [`style::ScrollAnimation`]. + pub fn scroll_with_delta_animation(&self, delta: Vec2, animation: style::ScrollAnimation) { self.ctx().frame_state_mut(|state| { - state.scroll_delta += delta; + state.scroll_delta.0 += delta; + state.scroll_delta.1 = animation; }); } }