1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Merge branch 'lucas/scroll-direction' into lucas/malmal/main

This commit is contained in:
lucasmerlin
2026-06-02 21:46:09 +02:00
2 changed files with 211 additions and 31 deletions

View File

@@ -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<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 {
@@ -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<bool>,
}
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<Response>,
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,

View File

@@ -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<AccessKitPassState>,
/// 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)]
{