From 5388e656234dcded437fb3905d5cba98ae2e6681 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Wed, 24 Jan 2024 16:40:20 +0100 Subject: [PATCH] Smooth scrolling (#3884) This adds smooth scrolling in egui. This makes scrolling in a `ScrollArea` using a notched mouse wheel a lot nicer. `InputState::scroll_delta` has been replaced by `InputState::raw_scroll_delta` and `InputState::smooth_scroll_delta`. --- crates/egui/src/containers/scroll_area.rs | 19 +++--- crates/egui/src/frame_state.rs | 8 --- crates/egui/src/input_state.rs | 73 ++++++++++++++++++++--- crates/egui/src/ui.rs | 2 +- crates/egui_plot/src/lib.rs | 2 +- 5 files changed, 77 insertions(+), 27 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 69bd2d964e0..d18cd80282b 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -564,6 +564,7 @@ impl ScrollArea { } } } 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); @@ -781,12 +782,12 @@ impl Prepared { && scroll_enabled[0] != scroll_enabled[1]; for d in 0..2 { if scroll_enabled[d] { - let scroll_delta = ui.ctx().frame_state(|fs| { + let scroll_delta = ui.ctx().input_mut(|input| { if always_scroll_enabled_direction { // no bidirectional scrolling; allow horizontal scrolling without pressing shift - fs.scroll_delta[0] + fs.scroll_delta[1] + input.smooth_scroll_delta[0] + input.smooth_scroll_delta[1] } else { - fs.scroll_delta[d] + input.smooth_scroll_delta[d] } }); @@ -795,15 +796,17 @@ impl Prepared { if scrolling_up || scrolling_down { state.offset[d] -= scroll_delta; - // Clear scroll delta so no parent scroll will use it. - ui.ctx().frame_state_mut(|fs| { + + // Clear scroll delta so no parent scroll will use it: + ui.ctx().input_mut(|input| { if always_scroll_enabled_direction { - fs.scroll_delta[0] = 0.0; - fs.scroll_delta[1] = 0.0; + input.smooth_scroll_delta[0] = 0.0; + input.smooth_scroll_delta[1] = 0.0; } else { - fs.scroll_delta[d] = 0.0; + input.smooth_scroll_delta[d] = 0.0; } }); + state.scroll_stuck_to_end[d] = false; } } diff --git a/crates/egui/src/frame_state.rs b/crates/egui/src/frame_state.rs index 49c3a5f1077..d94f1222aa6 100644 --- a/crates/egui/src/frame_state.rs +++ b/crates/egui/src/frame_state.rs @@ -38,11 +38,6 @@ pub(crate) struct FrameState { /// Initialized to `None` at the start of each frame. pub(crate) tooltip_state: Option, - /// Set to [`InputState::scroll_delta`] on the start of each frame. - /// - /// Cleared by the first [`ScrollArea`] that makes use of it. - pub(crate) scroll_delta: Vec2, // TODO(emilk): move to `InputState` ? - /// horizontal, vertical pub(crate) scroll_target: [Option<(Rangef, Option)>; 2], @@ -67,7 +62,6 @@ impl Default for FrameState { unused_rect: Rect::NAN, used_by_panels: Rect::NAN, tooltip_state: None, - scroll_delta: Vec2::ZERO, scroll_target: [None, None], #[cfg(feature = "accesskit")] accesskit_state: None, @@ -89,7 +83,6 @@ impl FrameState { unused_rect, used_by_panels, tooltip_state, - scroll_delta, scroll_target, #[cfg(feature = "accesskit")] accesskit_state, @@ -105,7 +98,6 @@ impl FrameState { *unused_rect = input.screen_rect(); *used_by_panels = Rect::NOTHING; *tooltip_state = None; - *scroll_delta = input.scroll_delta; *scroll_target = [None, None]; #[cfg(debug_assertions)] diff --git a/crates/egui/src/input_state.rs b/crates/egui/src/input_state.rs index cfc2073c537..8b82efbcd09 100644 --- a/crates/egui/src/input_state.rs +++ b/crates/egui/src/input_state.rs @@ -33,7 +33,24 @@ pub struct InputState { /// (We keep a separate [`TouchState`] for each encountered touch device.) touch_states: BTreeMap, - /// How many points the user scrolled. + /// Used for smoothing the scroll delta. + unprocessed_scroll_delta: Vec2, + + /// The raw input of how many points the user scrolled. + /// + /// The delta dictates how the _content_ should move. + /// + /// A positive X-value indicates the content is being moved right, + /// as when swiping right on a touch-screen or track-pad with natural scrolling. + /// + /// 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. + /// + /// When using a notched scroll-wheel this will spike very large for one frame, + /// then drop to zero. For a smoother experience, use [`Self::smooth_scroll_delta`]. + pub raw_scroll_delta: Vec2, + + /// How many points the user scrolled, smoothed over a few frames. /// /// The delta dictates how the _content_ should move. /// @@ -42,7 +59,10 @@ pub struct InputState { /// /// 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, + /// + /// [`crate::ScrollArea`] will both read and write to this field, so that + /// at the end of the frame this will be zero if a scroll-area consumed the delta. + pub smooth_scroll_delta: Vec2, /// Zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). /// @@ -125,7 +145,9 @@ impl Default for InputState { raw: Default::default(), pointer: Default::default(), touch_states: Default::default(), - scroll_delta: Vec2::ZERO, + unprocessed_scroll_delta: Vec2::ZERO, + raw_scroll_delta: Vec2::ZERO, + smooth_scroll_delta: Vec2::ZERO, zoom_factor_delta: 1.0, screen_rect: Rect::from_min_size(Default::default(), vec2(10_000.0, 10_000.0)), pixels_per_point: 1.0, @@ -171,7 +193,7 @@ impl InputState { let pointer = self.pointer.begin_frame(time, &new); let mut keys_down = self.keys_down; - let mut scroll_delta = Vec2::ZERO; + let mut raw_scroll_delta = Vec2::ZERO; let mut zoom_factor_delta = 1.0; for event in &mut new.events { match event { @@ -189,7 +211,7 @@ impl InputState { } } Event::Scroll(delta) => { - scroll_delta += *delta; + raw_scroll_delta += *delta; } Event::Zoom(factor) => { zoom_factor_delta *= *factor; @@ -198,6 +220,22 @@ impl InputState { } } + let mut unprocessed_scroll_delta = self.unprocessed_scroll_delta; + + let smooth_scroll_delta; + + { + // Mouse wheels often go very large steps. + // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw_scroll_delta. + // So we smooth it out over several frames for a nicer user experience when scrolling in egui. + unprocessed_scroll_delta += raw_scroll_delta; + 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; + } + let mut modifiers = new.modifiers; let focused_changed = self.focused != new.focused @@ -215,7 +253,9 @@ impl InputState { Self { pointer, touch_states: self.touch_states, - scroll_delta, + unprocessed_scroll_delta, + raw_scroll_delta, + smooth_scroll_delta, zoom_factor_delta, screen_rect, pixels_per_point, @@ -282,8 +322,11 @@ impl InputState { ) } + /// The [`crate::Context`] will call this at the end of each frame to see if we need a repaint. pub fn wants_repaint(&self) -> bool { - self.pointer.wants_repaint() || self.scroll_delta != Vec2::ZERO || !self.events.is_empty() + self.pointer.wants_repaint() + || self.unprocessed_scroll_delta.abs().max_elem() > 0.2 + || !self.events.is_empty() } /// Count presses of a key. If non-zero, the presses are consumed, so that this will only return non-zero once. @@ -1007,7 +1050,11 @@ impl InputState { raw, pointer, touch_states, - scroll_delta, + + unprocessed_scroll_delta, + raw_scroll_delta, + smooth_scroll_delta, + zoom_factor_delta, screen_rect, pixels_per_point, @@ -1042,7 +1089,15 @@ impl InputState { }); } - ui.label(format!("scroll_delta: {scroll_delta:?} points")); + if cfg!(debug_assertions) { + ui.label(format!( + "unprocessed_scroll_delta: {unprocessed_scroll_delta:?} points" + )); + } + ui.label(format!("raw_scroll_delta: {raw_scroll_delta:?} points")); + ui.label(format!( + "smooth_scroll_delta: {smooth_scroll_delta:?} points" + )); ui.label(format!("zoom_factor_delta: {zoom_factor_delta:4.2}x")); ui.label(format!("screen_rect: {screen_rect:?} points")); ui.label(format!( diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index cd255505b99..ceb3639a091 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -1042,7 +1042,7 @@ impl Ui { /// ``` pub fn scroll_with_delta(&self, delta: Vec2) { self.ctx() - .frame_state_mut(|state| state.scroll_delta += delta); + .input_mut(|input| input.smooth_scroll_delta += delta); } } diff --git a/crates/egui_plot/src/lib.rs b/crates/egui_plot/src/lib.rs index 55ec7007a15..5dc159f288f 100644 --- a/crates/egui_plot/src/lib.rs +++ b/crates/egui_plot/src/lib.rs @@ -1115,7 +1115,7 @@ impl Plot { } } if allow_scroll { - let scroll_delta = ui.input(|i| i.scroll_delta); + let scroll_delta = ui.input(|i| i.smooth_scroll_delta); if scroll_delta != Vec2::ZERO { transform.translate_bounds(-scroll_delta); auto_bounds = false.into();