`ScrollArea` improvements for user configurability (#5443)

* Closes <https://github.com/emilk/egui/issues/5406>
* [x] I have followed the instructions in the PR template

The changes follow what is described in the issue with a couple changes:
- Scroll bars are not hidden when dragging is disabled, for that
`ScrollArea::scroll_bar_visibility()` has to be used, this is as not to
limit the user configurability by imposing a specific function. The user
might want to retain the scrollbars visibility to show the current
position.
- The input for mouse wheel scrolling is unchanged. When I inspected the
code initially I made a mistake in recognizing the source of scrolling.
Current implementation is in fact using
`InputState::smooth_scroll_delta` and not `PassState::scroll_delta`,
therefore it is possible to prevent scrolling by setting the
`InputState::smooth_scroll_delta` to zero before painting the
`ScrollArea`.

A simple demo is available at
https://github.com/MStarha/egui_scroll_area_test
This commit is contained in:
MStarha 2025-04-25 11:01:22 +02:00 committed by GitHub
parent f9245954eb
commit f2ce6424f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 236 additions and 52 deletions

View File

@ -1,8 +1,10 @@
#![allow(clippy::needless_range_loop)]
use std::ops::{Add, AddAssign, BitOr, BitOrAssign};
use crate::{
emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, Context, Id, NumExt as _, Pos2,
Rangef, Rect, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b,
emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, Context, CursorIcon, Id,
NumExt as _, Pos2, Rangef, Rect, Sense, Ui, UiBuilder, UiKind, UiStackInfo, Vec2, Vec2b,
};
#[derive(Clone, Copy, Debug)]
@ -133,6 +135,113 @@ impl ScrollBarVisibility {
];
}
/// What is the source of scrolling for a [`ScrollArea`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct ScrollSource {
/// Scroll the area by dragging a scroll bar.
///
/// By default the scroll bars remain visible to show current position.
/// To hide them use [`ScrollArea::scroll_bar_visibility()`].
pub scroll_bar: bool,
/// Scroll the area by dragging the contents.
pub drag: bool,
/// Scroll the area by scrolling (or shift scrolling) the mouse wheel with
/// the mouse cursor over the [`ScrollArea`].
pub mouse_wheel: bool,
}
impl Default for ScrollSource {
fn default() -> Self {
Self::ALL
}
}
impl ScrollSource {
pub const NONE: Self = Self {
scroll_bar: false,
drag: false,
mouse_wheel: false,
};
pub const ALL: Self = Self {
scroll_bar: true,
drag: true,
mouse_wheel: true,
};
pub const SCROLL_BAR: Self = Self {
scroll_bar: true,
drag: false,
mouse_wheel: false,
};
pub const DRAG: Self = Self {
scroll_bar: false,
drag: true,
mouse_wheel: false,
};
pub const MOUSE_WHEEL: Self = Self {
scroll_bar: false,
drag: false,
mouse_wheel: true,
};
/// Is everything disabled?
#[inline]
pub fn is_none(&self) -> bool {
self == &Self::NONE
}
/// Is anything enabled?
#[inline]
pub fn any(&self) -> bool {
self.scroll_bar | self.drag | self.mouse_wheel
}
/// Is everything enabled?
#[inline]
pub fn is_all(&self) -> bool {
self.scroll_bar & self.drag & self.mouse_wheel
}
}
impl BitOr for ScrollSource {
type Output = Self;
#[inline]
fn bitor(self, rhs: Self) -> Self::Output {
Self {
scroll_bar: self.scroll_bar | rhs.scroll_bar,
drag: self.drag | rhs.drag,
mouse_wheel: self.mouse_wheel | rhs.mouse_wheel,
}
}
}
#[expect(clippy::suspicious_arithmetic_impl)]
impl Add for ScrollSource {
type Output = Self;
#[inline]
fn add(self, rhs: Self) -> Self::Output {
self | rhs
}
}
impl BitOrAssign for ScrollSource {
#[inline]
fn bitor_assign(&mut self, rhs: Self) {
*self = *self | rhs;
}
}
impl AddAssign for ScrollSource {
#[inline]
fn add_assign(&mut self, rhs: Self) {
*self = *self + rhs;
}
}
/// Add vertical and/or horizontal scrolling to a contained [`Ui`].
///
/// By default, scroll bars only show up when needed, i.e. when the contents
@ -168,7 +277,7 @@ impl ScrollBarVisibility {
#[must_use = "You should call .show()"]
pub struct ScrollArea {
/// Do we have horizontal/vertical scrolling enabled?
scroll_enabled: Vec2b,
direction_enabled: Vec2b,
auto_shrink: Vec2b,
max_size: Vec2,
@ -178,10 +287,10 @@ pub struct ScrollArea {
id_salt: Option<Id>,
offset_x: Option<f32>,
offset_y: Option<f32>,
/// If false, we ignore scroll events.
scrolling_enabled: bool,
drag_to_scroll: bool,
on_hover_cursor: Option<CursorIcon>,
on_drag_cursor: Option<CursorIcon>,
scroll_source: ScrollSource,
wheel_scroll_multiplier: Vec2,
/// If true for vertical or horizontal the scroll wheel will stick to the
/// end position until user manually changes position. It will become true
@ -220,9 +329,9 @@ impl ScrollArea {
/// Create a scroll area where you decide which axis has scrolling enabled.
/// For instance, `ScrollArea::new([true, false])` enables horizontal scrolling.
pub fn new(scroll_enabled: impl Into<Vec2b>) -> Self {
pub fn new(direction_enabled: impl Into<Vec2b>) -> Self {
Self {
scroll_enabled: scroll_enabled.into(),
direction_enabled: direction_enabled.into(),
auto_shrink: Vec2b::TRUE,
max_size: Vec2::INFINITY,
min_scrolled_size: Vec2::splat(64.0),
@ -231,8 +340,10 @@ impl ScrollArea {
id_salt: None,
offset_x: None,
offset_y: None,
scrolling_enabled: true,
drag_to_scroll: true,
on_hover_cursor: None,
on_drag_cursor: None,
scroll_source: ScrollSource::default(),
wheel_scroll_multiplier: Vec2::splat(1.0),
stick_to_end: Vec2b::FALSE,
animated: true,
}
@ -355,17 +466,41 @@ impl ScrollArea {
self
}
/// Set the cursor used when the mouse pointer is hovering over the [`ScrollArea`].
///
/// Only applies if [`Self::scroll_source()`] has set [`ScrollSource::drag`] to `true`.
///
/// Any changes to the mouse cursor made within the contents of the [`ScrollArea`] will
/// override this setting.
#[inline]
pub fn on_hover_cursor(mut self, cursor: CursorIcon) -> Self {
self.on_hover_cursor = Some(cursor);
self
}
/// Set the cursor used when the [`ScrollArea`] is being dragged.
///
/// Only applies if [`Self::scroll_source()`] has set [`ScrollSource::drag`] to `true`.
///
/// Any changes to the mouse cursor made within the contents of the [`ScrollArea`] will
/// override this setting.
#[inline]
pub fn on_drag_cursor(mut self, cursor: CursorIcon) -> Self {
self.on_drag_cursor = Some(cursor);
self
}
/// Turn on/off scrolling on the horizontal axis.
#[inline]
pub fn hscroll(mut self, hscroll: bool) -> Self {
self.scroll_enabled[0] = hscroll;
self.direction_enabled[0] = hscroll;
self
}
/// Turn on/off scrolling on the vertical axis.
#[inline]
pub fn vscroll(mut self, vscroll: bool) -> Self {
self.scroll_enabled[1] = vscroll;
self.direction_enabled[1] = vscroll;
self
}
@ -373,16 +508,16 @@ impl ScrollArea {
///
/// You can pass in `false`, `true`, `[false, true]` etc.
#[inline]
pub fn scroll(mut self, scroll_enabled: impl Into<Vec2b>) -> Self {
self.scroll_enabled = scroll_enabled.into();
pub fn scroll(mut self, direction_enabled: impl Into<Vec2b>) -> Self {
self.direction_enabled = direction_enabled.into();
self
}
/// Turn on/off scrolling on the horizontal/vertical axes.
#[deprecated = "Renamed to `scroll`"]
#[inline]
pub fn scroll2(mut self, scroll_enabled: impl Into<Vec2b>) -> Self {
self.scroll_enabled = scroll_enabled.into();
pub fn scroll2(mut self, direction_enabled: impl Into<Vec2b>) -> Self {
self.direction_enabled = direction_enabled.into();
self
}
@ -395,9 +530,14 @@ impl ScrollArea {
/// is typing text in a [`crate::TextEdit`] widget contained within the scroll area.
///
/// This controls both scrolling directions.
#[deprecated = "Use `ScrollArea::scroll_source()"]
#[inline]
pub fn enable_scrolling(mut self, enable: bool) -> Self {
self.scrolling_enabled = enable;
self.scroll_source = if enable {
ScrollSource::ALL
} else {
ScrollSource::NONE
};
self
}
@ -408,9 +548,28 @@ impl ScrollArea {
/// If `true`, the [`ScrollArea`] will sense drags.
///
/// Default: `true`.
#[deprecated = "Use `ScrollArea::scroll_source()"]
#[inline]
pub fn drag_to_scroll(mut self, drag_to_scroll: bool) -> Self {
self.drag_to_scroll = drag_to_scroll;
self.scroll_source.drag = drag_to_scroll;
self
}
/// What sources does the [`ScrollArea`] use for scrolling the contents.
#[inline]
pub fn scroll_source(mut self, scroll_source: ScrollSource) -> Self {
self.scroll_source = scroll_source;
self
}
/// The scroll amount caused by a mouse wheel scroll is multiplied by this amount.
///
/// Independent for each scroll direction. Defaults to `Vec2{x: 1.0, y: 1.0}`.
///
/// This can invert or effectively disable mouse scrolling.
#[inline]
pub fn wheel_scroll_multiplier(mut self, multiplier: Vec2) -> Self {
self.wheel_scroll_multiplier = multiplier;
self
}
@ -437,7 +596,7 @@ impl ScrollArea {
/// Is any scrolling enabled?
pub(crate) fn is_any_scroll_enabled(&self) -> bool {
self.scroll_enabled[0] || self.scroll_enabled[1]
self.direction_enabled[0] || self.direction_enabled[1]
}
/// The scroll handle will stick to the rightmost position even while the content size
@ -472,7 +631,7 @@ struct Prepared {
auto_shrink: Vec2b,
/// Does this `ScrollArea` have horizontal/vertical scrolling enabled?
scroll_enabled: Vec2b,
direction_enabled: Vec2b,
/// Smoothly interpolated boolean of whether or not to show the scroll bars.
show_bars_factor: Vec2,
@ -500,7 +659,8 @@ struct Prepared {
/// `viewport.min == ZERO` means we scrolled to the top.
viewport: Rect,
scrolling_enabled: bool,
scroll_source: ScrollSource,
wheel_scroll_multiplier: Vec2,
stick_to_end: Vec2b,
/// If there was a scroll target before the [`ScrollArea`] was added this frame, it's
@ -513,7 +673,7 @@ struct Prepared {
impl ScrollArea {
fn begin(self, ui: &mut Ui) -> Prepared {
let Self {
scroll_enabled,
direction_enabled,
auto_shrink,
max_size,
min_scrolled_size,
@ -522,14 +682,15 @@ impl ScrollArea {
id_salt,
offset_x,
offset_y,
scrolling_enabled,
drag_to_scroll,
on_hover_cursor,
on_drag_cursor,
scroll_source,
wheel_scroll_multiplier,
stick_to_end,
animated,
} = self;
let ctx = ui.ctx().clone();
let scrolling_enabled = scrolling_enabled && ui.is_enabled();
let id_salt = id_salt.unwrap_or_else(|| Id::new("scroll_area"));
let id = ui.make_persistent_id(id_salt);
@ -546,7 +707,7 @@ impl ScrollArea {
let show_bars: Vec2b = match scroll_bar_visibility {
ScrollBarVisibility::AlwaysHidden => Vec2b::FALSE,
ScrollBarVisibility::VisibleWhenNeeded => state.show_scroll,
ScrollBarVisibility::AlwaysVisible => scroll_enabled,
ScrollBarVisibility::AlwaysVisible => direction_enabled,
};
let show_bars_factor = Vec2::new(
@ -568,7 +729,7 @@ impl ScrollArea {
// one shouldn't collapse into nothingness.
// See https://github.com/emilk/egui/issues/1097
for d in 0..2 {
if scroll_enabled[d] {
if direction_enabled[d] {
inner_size[d] = inner_size[d].max(min_scrolled_size[d]);
}
}
@ -585,7 +746,7 @@ impl ScrollArea {
} else {
// Tell the inner Ui to use as much space as possible, we can scroll to see it!
for d in 0..2 {
if scroll_enabled[d] {
if direction_enabled[d] {
content_max_size[d] = f32::INFINITY;
}
}
@ -603,7 +764,7 @@ impl ScrollArea {
let clip_rect_margin = ui.visuals().clip_rect_margin;
let mut content_clip_rect = ui.clip_rect();
for d in 0..2 {
if scroll_enabled[d] {
if direction_enabled[d] {
content_clip_rect.min[d] = inner_rect.min[d] - clip_rect_margin;
content_clip_rect.max[d] = inner_rect.max[d] + clip_rect_margin;
} else {
@ -619,7 +780,8 @@ impl ScrollArea {
let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size);
let dt = ui.input(|i| i.stable_dt).at_most(0.1);
if (scrolling_enabled && drag_to_scroll)
if scroll_source.drag
&& ui.is_enabled()
&& (state.content_is_too_large[0] || state.content_is_too_large[1])
{
// Drag contents to scroll (for touch screens mostly).
@ -634,7 +796,7 @@ impl ScrollArea {
.is_some_and(|response| response.dragged())
{
for d in 0..2 {
if scroll_enabled[d] {
if direction_enabled[d] {
ui.input(|input| {
state.offset[d] -= input.pointer.delta()[d];
});
@ -649,7 +811,7 @@ impl ScrollArea {
.is_some_and(|response| response.drag_stopped())
{
state.vel =
scroll_enabled.to_vec2() * ui.input(|input| input.pointer.velocity());
direction_enabled.to_vec2() * ui.input(|input| input.pointer.velocity());
}
for d in 0..2 {
// Kinetic scrolling
@ -668,6 +830,19 @@ impl ScrollArea {
}
}
}
// Set the desired mouse cursors.
if let Some(response) = content_response_option {
if response.dragged() {
if let Some(cursor) = on_drag_cursor {
response.on_hover_cursor(cursor);
}
} else if response.hovered() {
if let Some(cursor) = on_hover_cursor {
response.on_hover_cursor(cursor);
}
}
}
}
// Scroll with an animation if we have a target offset (that hasn't been cleared by the code
@ -709,7 +884,7 @@ impl ScrollArea {
id,
state,
auto_shrink,
scroll_enabled,
direction_enabled,
show_bars_factor,
current_bar_use,
scroll_bar_visibility,
@ -717,7 +892,8 @@ impl ScrollArea {
inner_rect,
content_ui,
viewport,
scrolling_enabled,
scroll_source,
wheel_scroll_multiplier,
stick_to_end,
saved_scroll_target,
animated,
@ -824,14 +1000,15 @@ impl Prepared {
mut state,
inner_rect,
auto_shrink,
scroll_enabled,
direction_enabled,
mut show_bars_factor,
current_bar_use,
scroll_bar_visibility,
scroll_bar_rect,
content_ui,
viewport: _,
scrolling_enabled,
scroll_source,
wheel_scroll_multiplier,
stick_to_end,
saved_scroll_target,
animated,
@ -854,7 +1031,7 @@ impl Prepared {
.ctx()
.pass_state_mut(|state| state.scroll_target[d].take());
if scroll_enabled[d] {
if direction_enabled[d] {
if let Some(target) = scroll_target {
let pass_state::ScrollTarget {
range,
@ -930,7 +1107,7 @@ impl Prepared {
let mut inner_size = inner_rect.size();
for d in 0..2 {
inner_size[d] = match (scroll_enabled[d], auto_shrink[d]) {
inner_size[d] = match (direction_enabled[d], auto_shrink[d]) {
(true, true) => inner_size[d].min(content_size[d]), // shrink scroll area if content is small
(true, false) => inner_size[d], // let scroll area be larger than content; fill with blank space
(false, true) => content_size[d], // Follow the content (expand/contract to fit it).
@ -944,18 +1121,18 @@ impl Prepared {
let outer_rect = Rect::from_min_size(inner_rect.min, inner_rect.size() + current_bar_use);
let content_is_too_large = Vec2b::new(
scroll_enabled[0] && inner_rect.width() < content_size.x,
scroll_enabled[1] && inner_rect.height() < content_size.y,
direction_enabled[0] && inner_rect.width() < content_size.x,
direction_enabled[1] && inner_rect.height() < content_size.y,
);
let max_offset = content_size - inner_rect.size();
let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect);
if scrolling_enabled && is_hovering_outer_rect {
if scroll_source.mouse_wheel && ui.is_enabled() && is_hovering_outer_rect {
let always_scroll_enabled_direction = ui.style().always_scroll_the_only_direction
&& scroll_enabled[0] != scroll_enabled[1];
&& direction_enabled[0] != direction_enabled[1];
for d in 0..2 {
if scroll_enabled[d] {
let scroll_delta = ui.ctx().input_mut(|input| {
if direction_enabled[d] {
let scroll_delta = ui.ctx().input(|input| {
if always_scroll_enabled_direction {
// no bidirectional scrolling; allow horizontal scrolling without pressing shift
input.smooth_scroll_delta[0] + input.smooth_scroll_delta[1]
@ -963,6 +1140,7 @@ impl Prepared {
input.smooth_scroll_delta[d]
}
});
let scroll_delta = scroll_delta * wheel_scroll_multiplier[d];
let scrolling_up = state.offset[d] > 0.0 && scroll_delta > 0.0;
let scrolling_down = state.offset[d] < max_offset[d] && scroll_delta < 0.0;
@ -990,7 +1168,7 @@ impl Prepared {
let show_scroll_this_frame = match scroll_bar_visibility {
ScrollBarVisibility::AlwaysHidden => Vec2b::FALSE,
ScrollBarVisibility::VisibleWhenNeeded => content_is_too_large,
ScrollBarVisibility::AlwaysVisible => scroll_enabled,
ScrollBarVisibility::AlwaysVisible => direction_enabled,
};
// Avoid frame delay; start showing scroll bar right away:
@ -1120,7 +1298,7 @@ impl Prepared {
let handle_rect = calculate_handle_rect(d, &state.offset);
let interact_id = id.with(d);
let sense = if self.scrolling_enabled {
let sense = if scroll_source.scroll_bar && ui.is_enabled() {
Sense::click_and_drag()
} else {
Sense::hover()
@ -1170,7 +1348,7 @@ impl Prepared {
// Avoid frame-delay by calculating a new handle rect:
let handle_rect = calculate_handle_rect(d, &state.offset);
let visuals = if scrolling_enabled {
let visuals = if scroll_source.scroll_bar && ui.is_enabled() {
// Pick visuals based on interaction with the handle.
// Remember that the response is for the whole scroll bar!
let is_hovering_handle = response.hovered()

View File

@ -8,7 +8,7 @@ use epaint::{CornerRadiusF32, RectShape};
use crate::collapsing_header::CollapsingState;
use crate::*;
use super::scroll_area::ScrollBarVisibility;
use super::scroll_area::{ScrollBarVisibility, ScrollSource};
use super::{area, resize, Area, Frame, Resize, ScrollArea};
/// Builder for a floating window which can be dragged, closed, collapsed, resized and scrolled (off by default).
@ -403,7 +403,10 @@ impl<'open> Window<'open> {
/// See [`ScrollArea::drag_to_scroll`] for more.
#[inline]
pub fn drag_to_scroll(mut self, drag_to_scroll: bool) -> Self {
self.scroll = self.scroll.drag_to_scroll(drag_to_scroll);
self.scroll = self.scroll.scroll_source(ScrollSource {
drag: drag_to_scroll,
..Default::default()
});
self
}

View File

@ -4,7 +4,7 @@
//! Takes all available height, so if you want something below the table, put it in a strip.
use egui::{
scroll_area::{ScrollAreaOutput, ScrollBarVisibility},
scroll_area::{ScrollAreaOutput, ScrollBarVisibility, ScrollSource},
Align, Id, NumExt as _, Rangef, Rect, Response, ScrollArea, Ui, Vec2, Vec2b,
};
@ -745,7 +745,10 @@ impl Table<'_> {
let mut scroll_area = ScrollArea::new([false, vscroll])
.id_salt(state_id.with("__scroll_area"))
.drag_to_scroll(drag_to_scroll)
.scroll_source(ScrollSource {
drag: drag_to_scroll,
..Default::default()
})
.stick_to_bottom(stick_to_bottom)
.min_scrolled_height(min_scrolled_height)
.max_height(max_scroll_height)