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" category = "Window"
wasm = true 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]] [[example]]
name = "ui_material" name = "ui_material"
path = "examples/ui/ui_material.rs" 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(), x: node.overflow.x.into(),
y: node.overflow.y.into(), y: node.overflow.y.into(),
}, },
scrollbar_width: 0.0, scrollbar_width: node.scrollbar_width * context.scale_factor,
position: node.position_type.into(), position: node.position_type.into(),
flex_direction: node.flex_direction.into(), flex_direction: node.flex_direction.into(),
flex_wrap: node.flex_wrap.into(), flex_wrap: node.flex_wrap.into(),
@ -503,6 +503,7 @@ mod tests {
aspect_ratio: None, aspect_ratio: None,
overflow: crate::Overflow::clip(), overflow: crate::Overflow::clip(),
overflow_clip_margin: crate::OverflowClipMargin::default(), overflow_clip_margin: crate::OverflowClipMargin::default(),
scrollbar_width: 7.,
column_gap: Val::ZERO, column_gap: Val::ZERO,
row_gap: Val::ZERO, row_gap: Val::ZERO,
grid_auto_flow: GridAutoFlow::ColumnDense, 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.width, taffy::style::Dimension::Auto);
assert_eq!(taffy_style.max_size.height, taffy::style::Dimension::ZERO); assert_eq!(taffy_style.max_size.height, taffy::style::Dimension::ZERO);
assert_eq!(taffy_style.aspect_ratio, None); 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.width, taffy::style::LengthPercentage::ZERO);
assert_eq!(taffy_style.gap.height, taffy::style::LengthPercentage::ZERO); assert_eq!(taffy_style.gap.height, taffy::style::LengthPercentage::ZERO);
assert_eq!( assert_eq!(

View File

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

View File

@ -42,6 +42,14 @@ pub struct ComputedNode {
/// ///
/// Automatically calculated by [`super::layout::ui_layout_system`]. /// Automatically calculated by [`super::layout::ui_layout_system`].
pub content_size: Vec2, 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. /// The width of this node's outline.
/// If this value is `Auto`, negative or `0.` then no outline will be rendered. /// If this value is `Auto`, negative or `0.` then no outline will be rendered.
/// Outline updates bypass change detection. /// Outline updates bypass change detection.
@ -305,6 +313,8 @@ impl ComputedNode {
stack_index: 0, stack_index: 0,
size: Vec2::ZERO, size: Vec2::ZERO,
content_size: Vec2::ZERO, content_size: Vec2::ZERO,
scrollbar_size: Vec2::ZERO,
scroll_position: Vec2::ZERO,
outline_width: 0., outline_width: 0.,
outline_offset: 0., outline_offset: 0.,
unrounded_size: Vec2::ZERO, unrounded_size: Vec2::ZERO,
@ -419,6 +429,9 @@ pub struct Node {
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow> /// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow>
pub overflow: 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 /// How the bounds of clipped content should be determined
/// ///
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-clip-margin> /// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-clip-margin>
@ -703,6 +716,7 @@ impl Node {
aspect_ratio: None, aspect_ratio: None,
overflow: Overflow::DEFAULT, overflow: Overflow::DEFAULT,
overflow_clip_margin: OverflowClipMargin::DEFAULT, overflow_clip_margin: OverflowClipMargin::DEFAULT,
scrollbar_width: 0.,
row_gap: Val::ZERO, row_gap: Val::ZERO,
column_gap: Val::ZERO, column_gap: Val::ZERO,
grid_auto_flow: GridAutoFlow::DEFAULT, grid_auto_flow: GridAutoFlow::DEFAULT,

View File

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