Improved UI scrolling support and bug fixes (#20093)

# Objective

#### Goals
* Stop layout updates from overwriting `ScrollPosition`.
* Make `ScrollPosition` respect scale factor.
* Automatically allocate space for a scrollbar on an axis when
`OverflowAxis::Scroll` is set.
 
#### Non-Goals
* Overflow-auto support (I was certain Taffy had this already, but
apparently I was hallucinating).
* Implement any sort of scrollbar widgets.
* Stability (not needed because no overflow-auto support).
* Maybe in the future we could make a `ScrollbarWidth` enum to more
closely match the CSS API with its auto/narrow/none options. For now
`scrollbar_width` is just an `f32` which matches Taffy's API.

## Solution

* Layout updates no longer overwrite `ScrollPosition`'s value.
* Added the field `scrollbar_width: f32` to `Node`. This is sent to
`Taffy` which will automatically allocate space for scrollbars with this
width in the layout as needed.
* Added the fields `scrollbar_width: f32` and `scroll_position: Vec2` to
`ComputedNode`. These are updated automatically during layout.
* `ScrollPosition` now respects scale factor.
* `ScrollPosition` is no longer automatically added to every UI node
entity by `ui_layout_system`. If every node needs it, it should just be
required by (or be a field on) `Node`. Not sure if that's necessary or
not.

## Testing
For testing you can look at:
* The `scrollbars` example, which should work as before.
* The new example `drag_to_scroll`.
* The `scroll` example which automatically allocates space for
scrollbars on the left hand scrolling list. Did not implement actual
scrollbars so you'll just see a gap atm.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
ickshonpe 2025-07-15 18:33:04 +01:00 committed by GitHub
parent f774d6b7ed
commit d195116426
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 174 additions and 22 deletions

View File

@ -3832,6 +3832,17 @@ description = "Demonstrates resizing and responding to resizing a window"
category = "Window"
wasm = true
[[example]]
name = "drag_to_scroll"
path = "examples/ui/drag_to_scroll.rs"
doc-scrape-examples = true
[package.metadata.example.drag_to_scroll]
name = "Drag to Scroll"
description = "This example tests scale factor, dragging and scrolling"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "ui_material"
path = "examples/ui/ui_material.rs"

View File

@ -73,7 +73,7 @@ pub fn from_node(node: &Node, context: &LayoutContext, ignore_border: bool) -> t
x: node.overflow.x.into(),
y: node.overflow.y.into(),
},
scrollbar_width: 0.0,
scrollbar_width: node.scrollbar_width * context.scale_factor,
position: node.position_type.into(),
flex_direction: node.flex_direction.into(),
flex_wrap: node.flex_wrap.into(),
@ -503,6 +503,7 @@ mod tests {
aspect_ratio: None,
overflow: crate::Overflow::clip(),
overflow_clip_margin: crate::OverflowClipMargin::default(),
scrollbar_width: 7.,
column_gap: Val::ZERO,
row_gap: Val::ZERO,
grid_auto_flow: GridAutoFlow::ColumnDense,
@ -624,6 +625,7 @@ mod tests {
assert_eq!(taffy_style.max_size.width, taffy::style::Dimension::Auto);
assert_eq!(taffy_style.max_size.height, taffy::style::Dimension::ZERO);
assert_eq!(taffy_style.aspect_ratio, None);
assert_eq!(taffy_style.scrollbar_width, 7.);
assert_eq!(taffy_style.gap.width, taffy::style::LengthPercentage::ZERO);
assert_eq!(taffy_style.gap.height, taffy::style::LengthPercentage::ZERO);
assert_eq!(

View File

@ -10,7 +10,7 @@ use bevy_ecs::{
hierarchy::{ChildOf, Children},
lifecycle::RemovedComponents,
query::With,
system::{Commands, Query, ResMut},
system::{Query, ResMut},
world::Ref,
};
@ -71,7 +71,6 @@ pub enum LayoutError {
/// Updates the UI's layout tree, computes the new layout geometry and then updates the sizes and transforms of all the UI nodes.
pub fn ui_layout_system(
mut commands: Commands,
mut ui_surface: ResMut<UiSurface>,
ui_root_node_query: UiRootNodes,
mut node_query: Query<(
@ -172,7 +171,6 @@ with UI components as a child of an entity without UI components, your UI layout
);
update_uinode_geometry_recursive(
&mut commands,
ui_root_entity,
&mut ui_surface,
true,
@ -188,7 +186,6 @@ with UI components as a child of an entity without UI components, your UI layout
// Returns the combined bounding box of the node and any of its overflowing children.
fn update_uinode_geometry_recursive(
commands: &mut Commands,
entity: Entity,
ui_surface: &mut UiSurface,
inherited_use_rounding: bool,
@ -307,16 +304,19 @@ with UI components as a child of an entity without UI components, your UI layout
.max(0.);
}
node.bypass_change_detection().scrollbar_size =
Vec2::new(layout.scrollbar_size.width, layout.scrollbar_size.height);
let scroll_position: Vec2 = maybe_scroll_position
.map(|scroll_pos| {
Vec2::new(
if style.overflow.x == OverflowAxis::Scroll {
scroll_pos.x
scroll_pos.x * inverse_target_scale_factor.recip()
} else {
0.0
},
if style.overflow.y == OverflowAxis::Scroll {
scroll_pos.y
scroll_pos.y * inverse_target_scale_factor.recip()
} else {
0.0
},
@ -324,24 +324,16 @@ with UI components as a child of an entity without UI components, your UI layout
})
.unwrap_or_default();
let max_possible_offset = (content_size - layout_size).max(Vec2::ZERO);
let clamped_scroll_position = scroll_position.clamp(
Vec2::ZERO,
max_possible_offset * inverse_target_scale_factor,
);
let max_possible_offset =
(content_size - layout_size + node.scrollbar_size).max(Vec2::ZERO);
let clamped_scroll_position = scroll_position.clamp(Vec2::ZERO, max_possible_offset);
if clamped_scroll_position != scroll_position {
commands
.entity(entity)
.insert(ScrollPosition(clamped_scroll_position));
}
let physical_scroll_position = clamped_scroll_position.floor();
let physical_scroll_position =
(clamped_scroll_position / inverse_target_scale_factor).round();
node.bypass_change_detection().scroll_position = physical_scroll_position;
for child_uinode in ui_children.iter_ui_children(entity) {
update_uinode_geometry_recursive(
commands,
child_uinode,
ui_surface,
use_rounding,

View File

@ -42,6 +42,14 @@ pub struct ComputedNode {
///
/// Automatically calculated by [`super::layout::ui_layout_system`].
pub content_size: Vec2,
/// Space allocated for scrollbars.
///
/// Automatically calculated by [`super::layout::ui_layout_system`].
pub scrollbar_size: Vec2,
/// Resolved offset of scrolled content
///
/// Automatically calculated by [`super::layout::ui_layout_system`].
pub scroll_position: Vec2,
/// The width of this node's outline.
/// If this value is `Auto`, negative or `0.` then no outline will be rendered.
/// Outline updates bypass change detection.
@ -305,6 +313,8 @@ impl ComputedNode {
stack_index: 0,
size: Vec2::ZERO,
content_size: Vec2::ZERO,
scrollbar_size: Vec2::ZERO,
scroll_position: Vec2::ZERO,
outline_width: 0.,
outline_offset: 0.,
unrounded_size: Vec2::ZERO,
@ -419,6 +429,9 @@ pub struct Node {
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow>
pub overflow: Overflow,
/// How much space in logical pixels should be reserved for scrollbars when overflow is set to scroll or auto on an axis.
pub scrollbar_width: f32,
/// How the bounds of clipped content should be determined
///
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-clip-margin>
@ -703,6 +716,7 @@ impl Node {
aspect_ratio: None,
overflow: Overflow::DEFAULT,
overflow_clip_margin: OverflowClipMargin::DEFAULT,
scrollbar_width: 0.,
row_gap: Val::ZERO,
column_gap: Val::ZERO,
grid_auto_flow: GridAutoFlow::DEFAULT,

View File

@ -112,8 +112,8 @@ fn update_clipping(
clip_rect.min.x += clip_inset.left;
clip_rect.min.y += clip_inset.top;
clip_rect.max.x -= clip_inset.right;
clip_rect.max.y -= clip_inset.bottom;
clip_rect.max.x -= clip_inset.right + computed_node.scrollbar_size.x;
clip_rect.max.y -= clip_inset.bottom + computed_node.scrollbar_size.y;
clip_rect = clip_rect
.inflate(node.overflow_clip_margin.margin.max(0.) / computed_node.inverse_scale_factor);

View File

@ -553,6 +553,7 @@ Example | Description
[Core Widgets (w/Observers)](../examples/ui/core_widgets_observers.rs) | Demonstrates use of core (headless) widgets in Bevy UI, with Observers
[Directional Navigation](../examples/ui/directional_navigation.rs) | Demonstration of Directional Navigation between UI elements
[Display and Visibility](../examples/ui/display_and_visibility.rs) | Demonstrates how Display and Visibility work in the UI.
[Drag to Scroll](../examples/ui/drag_to_scroll.rs) | This example tests scale factor, dragging and scrolling
[Flex Layout](../examples/ui/flex_layout.rs) | Demonstrates how the AlignItems and JustifyContent properties can be composed to layout nodes and position text
[Font Atlas Debug](../examples/ui/font_atlas_debug.rs) | Illustrates how FontAtlases are populated (used to optimize text rendering internally)
[Ghost Nodes](../examples/ui/ghost_nodes.rs) | Demonstrates the use of Ghost Nodes to skip entities in the UI layout hierarchy

View File

@ -0,0 +1,120 @@
//! This example tests scale factor, dragging and scrolling
use bevy::color::palettes::css::RED;
use bevy::prelude::*;
#[derive(Component)]
struct DragNode;
#[derive(Component)]
struct ScrollableNode;
#[derive(Component)]
struct TileColor(Color);
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
#[derive(Component)]
struct ScrollStart(Vec2);
fn setup(mut commands: Commands) {
let w = 60;
let h = 40;
commands.spawn(Camera2d);
commands.insert_resource(UiScale(0.5));
commands
.spawn((
Node {
width: Val::Percent(100.),
height: Val::Percent(100.),
overflow: Overflow::scroll(),
..Default::default()
},
ScrollPosition(Vec2::ZERO),
ScrollableNode,
ScrollStart(Vec2::ZERO),
))
.observe(
|
drag: On<Pointer<Drag>>,
ui_scale: Res<UiScale>,
mut scroll_position_query: Query<(
&mut ScrollPosition,
&ScrollStart),
With<ScrollableNode>,
>| {
if let Ok((mut scroll_position, start)) = scroll_position_query.single_mut() {
scroll_position.0 = (start.0 - drag.distance / ui_scale.0).max(Vec2::ZERO);
}
},
)
.observe(
|
on: On<Pointer<DragStart>>,
mut scroll_position_query: Query<(
&ComputedNode,
&mut ScrollStart),
With<ScrollableNode>,
>| {
if on.target() != on.original_target() {
return;
}
if let Ok((computed_node, mut start)) = scroll_position_query.single_mut() {
start.0 = computed_node.scroll_position * computed_node.inverse_scale_factor;
}
},
)
.with_children(|commands| {
commands
.spawn(Node {
display: Display::Grid,
grid_template_rows: RepeatedGridTrack::px(w as i32, 100.),
grid_template_columns: RepeatedGridTrack::px(h as i32, 100.),
..Default::default()
})
.with_children(|commands| {
for y in 0..h {
for x in 0..w {
let tile_color = if (x + y) % 2 == 1 {
let hue = ((x as f32 / w as f32) * 270.0) + ((y as f32 / h as f32) * 90.0);
Color::hsl(hue, 1., 0.5)
} else {
Color::BLACK
};
commands
.spawn((
Node {
grid_row: GridPlacement::start(y + 1),
grid_column: GridPlacement::start(x + 1),
..Default::default()
},
Pickable {
should_block_lower: false,
is_hoverable: true,
},
TileColor(tile_color),
BackgroundColor(tile_color),
))
.observe(|on_enter: On<Pointer<Over>>, mut query: Query<&mut BackgroundColor>, | {
if let Ok(mut background_color) = query.get_mut(on_enter.target()) {
background_color.0 = RED.into();
}
})
.observe(|on_enter: On<Pointer<Out>>, mut query: Query<(&mut BackgroundColor, &TileColor)>,| {
if let Ok((mut background_color, tile_color)) = query.get_mut(on_enter.target()) {
background_color.0 = tile_color.0;
}
});
}
}
});
});
}

View File

@ -0,0 +1,5 @@
---
title: `ScrollPosition` now uses logical pixel units and is no longer overwritten during layout updates
pull_requests: [20093]
---
`ScrollPosition` is no longer overwritten during layout updates. Instead the computed scroll position is stored in the new `scroll_position` field on `ComputedNode`.

View File

@ -0,0 +1,7 @@
---
title: Automatically allocate space for scrollbars
authors: ["@ickshonpe"]
pull_requests: [20093]
---
`Node` has a new field `scrollbar_width`. If `OverflowAxis::Scroll` is set for a UI Node's axis, a space for a scrollbars of width `scrollbar_width` will automatically be left in the layout.