mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -04:00
Touch scroll budget for scrolling across nested ScrollAreas
This commit is contained in:
@@ -53,6 +53,20 @@ pub struct State {
|
|||||||
|
|
||||||
/// Area that can be dragged. This is the size of the content from the last frame.
|
/// Area that can be dragged. This is the size of the content from the last frame.
|
||||||
interact_rect: Option<Rect>,
|
interact_rect: Option<Rect>,
|
||||||
|
|
||||||
|
/// 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<usize>,
|
||||||
|
|
||||||
|
/// 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 {
|
impl Default for State {
|
||||||
@@ -67,6 +81,8 @@ impl Default for State {
|
|||||||
scroll_start_offset_from_top_left: [None; 2],
|
scroll_start_offset_from_top_left: [None; 2],
|
||||||
scroll_stuck_to_end: Vec2b::TRUE,
|
scroll_stuck_to_end: Vec2b::TRUE,
|
||||||
interact_rect: None,
|
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
|
/// If false, `scroll_to_*` functions will not be animated
|
||||||
animated: bool,
|
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<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollArea {
|
impl ScrollArea {
|
||||||
@@ -409,6 +429,7 @@ impl ScrollArea {
|
|||||||
content_margin: None,
|
content_margin: None,
|
||||||
stick_to_end: Vec2b::FALSE,
|
stick_to_end: Vec2b::FALSE,
|
||||||
animated: true,
|
animated: true,
|
||||||
|
drag_bubbling: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,6 +567,24 @@ impl ScrollArea {
|
|||||||
self
|
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.
|
/// Turn on/off scrolling on the horizontal axis.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn hscroll(mut self, hscroll: bool) -> Self {
|
pub fn hscroll(mut self, hscroll: bool) -> Self {
|
||||||
@@ -705,6 +744,10 @@ struct Prepared {
|
|||||||
background_drag_response: Option<Response>,
|
background_drag_response: Option<Response>,
|
||||||
|
|
||||||
animated: bool,
|
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 {
|
impl ScrollArea {
|
||||||
@@ -726,8 +769,13 @@ impl ScrollArea {
|
|||||||
content_margin: _, // Used elsewhere
|
content_margin: _, // Used elsewhere
|
||||||
stick_to_end,
|
stick_to_end,
|
||||||
animated,
|
animated,
|
||||||
|
drag_bubbling,
|
||||||
} = self;
|
} = 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 ctx = ui.ctx().clone();
|
||||||
|
|
||||||
let id_salt = id_salt.unwrap_or_else(|| IdSalt::new("scroll_area"));
|
let id_salt = id_salt.unwrap_or_else(|| IdSalt::new("scroll_area"));
|
||||||
@@ -839,40 +887,50 @@ impl ScrollArea {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|response| response.dragged())
|
.is_some_and(|response| response.dragged())
|
||||||
{
|
{
|
||||||
for d in 0..2 {
|
// Seed the shared drag-scroll budget with this gesture's pointer delta. The
|
||||||
if direction_enabled[d] {
|
// actual scrolling — and any bubbling to an enclosing scroll area — happens in
|
||||||
ui.input(|input| {
|
// `end()`, where the content size (and thus the scroll limit) is known.
|
||||||
state.offset[d] -= input.pointer.delta()[d];
|
let mut delta = ui.input(|input| input.pointer.delta());
|
||||||
});
|
|
||||||
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.
|
|
||||||
|
|
||||||
let friction = friction_coeff * dt;
|
// Axis-lock for single-axis areas: once the gesture decidedly commits to a
|
||||||
if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed {
|
// direction, ignore the minor axis so a near-straight drag doesn't wobble the
|
||||||
state.vel[d] = 0.0;
|
// cross axis (and a vertical drag in a horizontal area cleanly scrolls the
|
||||||
} else {
|
// parent). 2D (`both()`) areas never lock, so they can pan diagonally.
|
||||||
state.vel[d] -= friction * state.vel[d].signum();
|
if direction_enabled[0] != direction_enabled[1] {
|
||||||
// Offset has an inverted coordinate system compared to
|
if state.drag_axis_lock.is_none() {
|
||||||
// the velocity, so we subtract it instead of adding it
|
let committed = ui.input(|input| {
|
||||||
state.offset[d] -= state.vel[d] * dt;
|
input
|
||||||
ctx.request_repaint();
|
.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.
|
// Set the desired mouse cursors.
|
||||||
@@ -946,6 +1004,7 @@ impl ScrollArea {
|
|||||||
saved_scroll_target,
|
saved_scroll_target,
|
||||||
background_drag_response,
|
background_drag_response,
|
||||||
animated,
|
animated,
|
||||||
|
drag_bubbling,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1132,7 @@ impl Prepared {
|
|||||||
saved_scroll_target,
|
saved_scroll_target,
|
||||||
background_drag_response,
|
background_drag_response,
|
||||||
animated,
|
animated,
|
||||||
|
drag_bubbling,
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
let content_size = content_ui.min_size();
|
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 {
|
let show_scroll_this_frame = match scroll_bar_visibility {
|
||||||
ScrollBarVisibility::AlwaysHidden => Vec2b::FALSE,
|
ScrollBarVisibility::AlwaysHidden => Vec2b::FALSE,
|
||||||
ScrollBarVisibility::VisibleWhenNeeded => content_is_too_large,
|
ScrollBarVisibility::VisibleWhenNeeded => content_is_too_large,
|
||||||
|
|||||||
@@ -223,6 +223,17 @@ pub struct PassState {
|
|||||||
/// as when swiping down on a touch-screen or track-pad with natural scrolling.
|
/// as when swiping down on a touch-screen or track-pad with natural scrolling.
|
||||||
pub scroll_delta: (Vec2, style::ScrollAnimation),
|
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<AccessKitPassState>,
|
pub accesskit_state: Option<AccessKitPassState>,
|
||||||
|
|
||||||
/// Highlight these widgets the next pass.
|
/// Highlight these widgets the next pass.
|
||||||
@@ -243,6 +254,8 @@ impl Default for PassState {
|
|||||||
root_ui_min_rect: None,
|
root_ui_min_rect: None,
|
||||||
scroll_target: [None, None],
|
scroll_target: [None, None],
|
||||||
scroll_delta: (Vec2::default(), style::ScrollAnimation::none()),
|
scroll_delta: (Vec2::default(), style::ScrollAnimation::none()),
|
||||||
|
drag_scroll_budget: Vec2::ZERO,
|
||||||
|
drag_scroll_fling: Vec2::ZERO,
|
||||||
accesskit_state: None,
|
accesskit_state: None,
|
||||||
highlight_next_pass: Default::default(),
|
highlight_next_pass: Default::default(),
|
||||||
|
|
||||||
@@ -264,6 +277,8 @@ impl PassState {
|
|||||||
root_ui_min_rect,
|
root_ui_min_rect,
|
||||||
scroll_target,
|
scroll_target,
|
||||||
scroll_delta,
|
scroll_delta,
|
||||||
|
drag_scroll_budget,
|
||||||
|
drag_scroll_fling,
|
||||||
accesskit_state,
|
accesskit_state,
|
||||||
highlight_next_pass,
|
highlight_next_pass,
|
||||||
|
|
||||||
@@ -279,6 +294,8 @@ impl PassState {
|
|||||||
*root_ui_min_rect = None;
|
*root_ui_min_rect = None;
|
||||||
*scroll_target = [None, None];
|
*scroll_target = [None, None];
|
||||||
*scroll_delta = Default::default();
|
*scroll_delta = Default::default();
|
||||||
|
*drag_scroll_budget = Vec2::ZERO;
|
||||||
|
*drag_scroll_fling = Vec2::ZERO;
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user