From 296ffa6123f89c1e18abf6c665b97d5929364063 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Tue, 2 Jun 2026 21:11:17 +0200 Subject: [PATCH] Touch scroll budget for scrolling across nested ScrollAreas --- crates/egui/src/containers/scroll_area.rs | 225 +++++++++++++++++++--- crates/egui/src/pass_state.rs | 17 ++ 2 files changed, 211 insertions(+), 31 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 5e0c69936..026563f3a 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -53,6 +53,20 @@ pub struct State { /// Area that can be dragged. This is the size of the content from the last frame. interact_rect: Option, + + /// While drag-to-scrolling a single-axis area, the axis (0 = X, 1 = Y) the gesture has + /// committed to. Minor-axis motion is then ignored so a near-straight drag doesn't wobble + /// the cross axis. `None` until the drag is decidedly dragging; reset when the drag ends. + /// `both()` (2D) areas never lock so they can pan diagonally. + #[cfg_attr(feature = "serde", serde(skip))] + drag_axis_lock: Option, + + /// Was this area part of the drag-scroll gesture chain (it owned the drag, or the pointer + /// was over it) during the active drag? Used to route the release "fling" to the right areas + /// even after the pointer is gone (lifting a finger fires `PointerGone`, so the live pointer + /// can't be used). Reset once the gesture's motion ends. + #[cfg_attr(feature = "serde", serde(skip))] + drag_received: bool, } impl Default for State { @@ -67,6 +81,8 @@ impl Default for State { scroll_start_offset_from_top_left: [None; 2], scroll_stuck_to_end: Vec2b::TRUE, interact_rect: None, + drag_axis_lock: None, + drag_received: false, } } } @@ -361,6 +377,10 @@ pub struct ScrollArea { /// If false, `scroll_to_*` functions will not be animated animated: bool, + + /// Whether drag-to-scroll motion this area can't use bubbles to an enclosing [`ScrollArea`]. + /// `None` = default (on for single-axis areas, off for 2D `both()` areas). + drag_bubbling: Option, } impl ScrollArea { @@ -409,6 +429,7 @@ impl ScrollArea { content_margin: None, stick_to_end: Vec2b::FALSE, animated: true, + drag_bubbling: None, } } @@ -546,6 +567,24 @@ impl ScrollArea { self } + /// Control whether drag-to-scroll motion this area can't use bubbles to an enclosing + /// [`ScrollArea`]. + /// + /// This enables nested touch / drag scrolling: dragging across the cross axis of an inner + /// area scrolls the enclosing area (e.g. dragging *down* in a horizontal area inside a + /// vertical one), and same-axis overscroll is handed to the parent once the inner area + /// reaches its end. + /// + /// Defaults to `true` for single-axis areas and `false` for 2D ([`Self::both`]) areas, so + /// that panning a 2D area doesn't scroll an enclosing area when it reaches an edge. + /// + /// Only affects the [`ScrollSource::drag`] path; the mouse wheel always bubbles. + #[inline] + pub fn drag_bubbling(mut self, drag_bubbling: bool) -> Self { + self.drag_bubbling = Some(drag_bubbling); + self + } + /// Turn on/off scrolling on the horizontal axis. #[inline] pub fn hscroll(mut self, hscroll: bool) -> Self { @@ -705,6 +744,10 @@ struct Prepared { background_drag_response: Option, animated: bool, + + /// Whether drag-to-scroll motion this area can't use bubbles to an enclosing [`ScrollArea`]. + /// Already resolved from the per-kind default in `begin()`. + drag_bubbling: bool, } impl ScrollArea { @@ -726,8 +769,13 @@ impl ScrollArea { content_margin: _, // Used elsewhere stick_to_end, animated, + drag_bubbling, } = self; + // Default: single-axis areas bubble unused drag motion to a parent; 2D areas capture it + // (so panning a map doesn't scroll the page at its edges). + let drag_bubbling = drag_bubbling.unwrap_or(direction_enabled[0] != direction_enabled[1]); + let ctx = ui.ctx().clone(); let id_salt = id_salt.unwrap_or_else(|| IdSalt::new("scroll_area")); @@ -839,40 +887,50 @@ impl ScrollArea { .as_ref() .is_some_and(|response| response.dragged()) { - for d in 0..2 { - if direction_enabled[d] { - ui.input(|input| { - state.offset[d] -= input.pointer.delta()[d]; - }); - state.scroll_stuck_to_end[d] = false; - state.offset_target[d] = None; - } - } - } else { - // Apply the cursor velocity to the scroll area when the user releases the drag. - if content_response_option - .as_ref() - .is_some_and(|response| response.drag_stopped()) - { - state.vel = - direction_enabled.to_vec2() * ui.input(|input| input.pointer.velocity()); - } - for d in 0..2 { - // Kinetic scrolling - let stop_speed = 20.0; // Pixels per second. - let friction_coeff = 1000.0; // Pixels per second squared. + // Seed the shared drag-scroll budget with this gesture's pointer delta. The + // actual scrolling — and any bubbling to an enclosing scroll area — happens in + // `end()`, where the content size (and thus the scroll limit) is known. + let mut delta = ui.input(|input| input.pointer.delta()); - 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(); + // Axis-lock for single-axis areas: once the gesture decidedly commits to a + // direction, ignore the minor axis so a near-straight drag doesn't wobble the + // cross axis (and a vertical drag in a horizontal area cleanly scrolls the + // parent). 2D (`both()`) areas never lock, so they can pan diagonally. + if direction_enabled[0] != direction_enabled[1] { + if state.drag_axis_lock.is_none() { + let committed = ui.input(|input| { + input + .pointer + .is_decidedly_dragging() + .then(|| input.pointer.total_drag_delta()) + .flatten() + }); + if let Some(total) = committed { + // Lock to whichever axis the gesture has moved farthest along. + let dominant_axis = usize::from(total.x.abs() < total.y.abs()); // 0=X, 1=Y + state.drag_axis_lock = Some(dominant_axis); + } + } + match state.drag_axis_lock { + Some(0) => delta.y = 0.0, + Some(1) => delta.x = 0.0, + // Not yet committed: only act on this area's own axis, so we don't bubble + // the cross axis to a parent before the gesture clearly commits to it. + _ => { + if !direction_enabled[0] { + delta.x = 0.0; + } + if !direction_enabled[1] { + delta.y = 0.0; + } + } } } + + ui.ctx().pass_state_mut(|s| s.drag_scroll_budget = delta); + } else { + // Drag ended (or none this frame): allow a fresh axis lock for the next gesture. + state.drag_axis_lock = None; } // Set the desired mouse cursors. @@ -946,6 +1004,7 @@ impl ScrollArea { saved_scroll_target, background_drag_response, animated, + drag_bubbling, } } @@ -1073,6 +1132,7 @@ impl Prepared { saved_scroll_target, background_drag_response, animated, + drag_bubbling, } = self; let content_size = content_ui.min_size(); @@ -1240,6 +1300,109 @@ impl Prepared { } } + // Drag-to-scroll (touch / `ScrollSource::drag`), consuming the shared budget seeded by + // the dragged scroll area in `begin()`. Like the wheel above, we take only what we can + // use on each enabled axis and leave the rest for an enclosing scroll area — `end()` runs + // inner-first, so the inner area consumes before its parents. + if scroll_source.drag.enabled(ui.ctx()) && ui.is_enabled() { + // This area is part of the gesture if it owns the drag, or the pointer is over it + // (so it is an ancestor of the owner). Unlike the wheel, we deliberately do *not* + // require `dragged_id().is_none()`, since a drag is in progress. + let receive = is_dragging_background || ui.rect_contains_pointer(outer_rect); + let drag_active = ui.ctx().dragged_id().is_some(); + + if receive { + let mut budget = ui.ctx().pass_state(|s| s.drag_scroll_budget); + for d in 0..2 { + if direction_enabled[d] && budget[d] != 0.0 { + // Consume exactly the room we have on this axis and bubble the signed + // remainder this frame (no dropped frame at the saturation boundary). + let new_offset = + (state.offset[d] - budget[d]).clamp(0.0, max_offset[d].max(0.0)); + let consumed = state.offset[d] - new_offset; + state.offset[d] = new_offset; + budget[d] -= consumed; + if consumed != 0.0 { + state.scroll_stuck_to_end[d] = false; + state.offset_target[d] = None; + ui.ctx().request_repaint(); + } + } + } + if !drag_bubbling { + // Capture: don't pass the remainder to an enclosing scroll area. + budget = Vec2::ZERO; + } + ui.ctx().pass_state_mut(|s| s.drag_scroll_budget = budget); + + if drag_active { + // Remember chain membership so we still receive the release fling once the + // pointer is gone (see `State::drag_received`). + state.drag_received = true; + } + } + + // Kinetic scrolling / fling. Runs here (rather than in `begin()`) so an inner area + // can hand the release velocity it can't use to an enclosing scroll area. + let dt = ui.input(|i| i.stable_dt).at_most(0.1); + if receive && drag_active { + // An active drag controls the motion directly; cancel any leftover fling. + state.vel = Vec2::ZERO; + } else { + // Adopt fling velocity handed down by an inner scroll area (inner-first `end()`). + // Gated on gesture-chain membership rather than the live pointer, since lifting a + // finger fires `PointerGone` on the very frame the fling is seeded. + if state.drag_received { + let mut fling = ui.ctx().pass_state(|s| s.drag_scroll_fling); + for d in 0..2 { + if direction_enabled[d] && fling[d] != 0.0 { + state.vel[d] += fling[d]; + fling[d] = 0.0; + } + } + if !drag_bubbling { + fling = Vec2::ZERO; + } + ui.ctx().pass_state_mut(|s| s.drag_scroll_fling = fling); + } + + // When the user releases the drag, seed our own kinetic velocity and bubble the + // cross-axis component so an enclosing area can fling on the axis we can't use. + if background_drag_response + .as_ref() + .is_some_and(|response| response.drag_stopped()) + { + let velocity = ui.input(|input| input.pointer.velocity()); + state.vel = direction_enabled.to_vec2() * velocity; + if drag_bubbling { + let bubbled = (!direction_enabled).to_vec2() * velocity; + ui.ctx().pass_state_mut(|s| s.drag_scroll_fling += bubbled); + } + } + + // The gesture's motion has ended; clear chain membership (the fling now lives in + // each area's own `vel` and decays independently). + state.drag_received = false; + + for d in 0..2 { + // 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; + ui.ctx().request_repaint(); + } + } + } + } + let show_scroll_this_frame = match scroll_bar_visibility { ScrollBarVisibility::AlwaysHidden => Vec2b::FALSE, ScrollBarVisibility::VisibleWhenNeeded => content_is_too_large, diff --git a/crates/egui/src/pass_state.rs b/crates/egui/src/pass_state.rs index 3beef87fa..acdc0013d 100644 --- a/crates/egui/src/pass_state.rs +++ b/crates/egui/src/pass_state.rs @@ -223,6 +223,17 @@ pub struct PassState { /// as when swiping down on a touch-screen or track-pad with natural scrolling. pub scroll_delta: (Vec2, style::ScrollAnimation), + /// Unconsumed touch-/drag-scroll delta (pixels), shared between nested scroll areas so an + /// inner area can hand cross-axis or overscroll motion to an enclosing one. + /// + /// Seeded by the dragged [`crate::ScrollArea`] in `begin()` and consumed inner-first in + /// `end()`, mirroring how the mouse wheel bubbles via [`crate::InputState::smooth_scroll_delta`]. + pub(crate) drag_scroll_budget: Vec2, + + /// Like [`Self::drag_scroll_budget`] but for release "fling" velocity (pixels per second): + /// an inner area hands the velocity it can't use to an enclosing area so the fling continues. + pub(crate) drag_scroll_fling: Vec2, + pub accesskit_state: Option, /// Highlight these widgets the next pass. @@ -243,6 +254,8 @@ impl Default for PassState { root_ui_min_rect: None, scroll_target: [None, None], scroll_delta: (Vec2::default(), style::ScrollAnimation::none()), + drag_scroll_budget: Vec2::ZERO, + drag_scroll_fling: Vec2::ZERO, accesskit_state: None, highlight_next_pass: Default::default(), @@ -264,6 +277,8 @@ impl PassState { root_ui_min_rect, scroll_target, scroll_delta, + drag_scroll_budget, + drag_scroll_fling, accesskit_state, highlight_next_pass, @@ -279,6 +294,8 @@ impl PassState { *root_ui_min_rect = None; *scroll_target = [None, None]; *scroll_delta = Default::default(); + *drag_scroll_budget = Vec2::ZERO; + *drag_scroll_fling = Vec2::ZERO; #[cfg(debug_assertions)] {