Skip to content

Commit d195116

Browse files
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>
1 parent f774d6b commit d195116

File tree

9 files changed

+174
-22
lines changed

9 files changed

+174
-22
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3832,6 +3832,17 @@ description = "Demonstrates resizing and responding to resizing a window"
38323832
category = "Window"
38333833
wasm = true
38343834

3835+
[[example]]
3836+
name = "drag_to_scroll"
3837+
path = "examples/ui/drag_to_scroll.rs"
3838+
doc-scrape-examples = true
3839+
3840+
[package.metadata.example.drag_to_scroll]
3841+
name = "Drag to Scroll"
3842+
description = "This example tests scale factor, dragging and scrolling"
3843+
category = "UI (User Interface)"
3844+
wasm = true
3845+
38353846
[[example]]
38363847
name = "ui_material"
38373848
path = "examples/ui/ui_material.rs"

crates/bevy_ui/src/layout/convert.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ pub fn from_node(node: &Node, context: &LayoutContext, ignore_border: bool) -> t
7373
x: node.overflow.x.into(),
7474
y: node.overflow.y.into(),
7575
},
76-
scrollbar_width: 0.0,
76+
scrollbar_width: node.scrollbar_width * context.scale_factor,
7777
position: node.position_type.into(),
7878
flex_direction: node.flex_direction.into(),
7979
flex_wrap: node.flex_wrap.into(),
@@ -503,6 +503,7 @@ mod tests {
503503
aspect_ratio: None,
504504
overflow: crate::Overflow::clip(),
505505
overflow_clip_margin: crate::OverflowClipMargin::default(),
506+
scrollbar_width: 7.,
506507
column_gap: Val::ZERO,
507508
row_gap: Val::ZERO,
508509
grid_auto_flow: GridAutoFlow::ColumnDense,
@@ -624,6 +625,7 @@ mod tests {
624625
assert_eq!(taffy_style.max_size.width, taffy::style::Dimension::Auto);
625626
assert_eq!(taffy_style.max_size.height, taffy::style::Dimension::ZERO);
626627
assert_eq!(taffy_style.aspect_ratio, None);
628+
assert_eq!(taffy_style.scrollbar_width, 7.);
627629
assert_eq!(taffy_style.gap.width, taffy::style::LengthPercentage::ZERO);
628630
assert_eq!(taffy_style.gap.height, taffy::style::LengthPercentage::ZERO);
629631
assert_eq!(

crates/bevy_ui/src/layout/mod.rs

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use bevy_ecs::{
1010
hierarchy::{ChildOf, Children},
1111
lifecycle::RemovedComponents,
1212
query::With,
13-
system::{Commands, Query, ResMut},
13+
system::{Query, ResMut},
1414
world::Ref,
1515
};
1616

@@ -71,7 +71,6 @@ pub enum LayoutError {
7171

7272
/// Updates the UI's layout tree, computes the new layout geometry and then updates the sizes and transforms of all the UI nodes.
7373
pub fn ui_layout_system(
74-
mut commands: Commands,
7574
mut ui_surface: ResMut<UiSurface>,
7675
ui_root_node_query: UiRootNodes,
7776
mut node_query: Query<(
@@ -172,7 +171,6 @@ with UI components as a child of an entity without UI components, your UI layout
172171
);
173172

174173
update_uinode_geometry_recursive(
175-
&mut commands,
176174
ui_root_entity,
177175
&mut ui_surface,
178176
true,
@@ -188,7 +186,6 @@ with UI components as a child of an entity without UI components, your UI layout
188186

189187
// Returns the combined bounding box of the node and any of its overflowing children.
190188
fn update_uinode_geometry_recursive(
191-
commands: &mut Commands,
192189
entity: Entity,
193190
ui_surface: &mut UiSurface,
194191
inherited_use_rounding: bool,
@@ -307,41 +304,36 @@ with UI components as a child of an entity without UI components, your UI layout
307304
.max(0.);
308305
}
309306

307+
node.bypass_change_detection().scrollbar_size =
308+
Vec2::new(layout.scrollbar_size.width, layout.scrollbar_size.height);
309+
310310
let scroll_position: Vec2 = maybe_scroll_position
311311
.map(|scroll_pos| {
312312
Vec2::new(
313313
if style.overflow.x == OverflowAxis::Scroll {
314-
scroll_pos.x
314+
scroll_pos.x * inverse_target_scale_factor.recip()
315315
} else {
316316
0.0
317317
},
318318
if style.overflow.y == OverflowAxis::Scroll {
319-
scroll_pos.y
319+
scroll_pos.y * inverse_target_scale_factor.recip()
320320
} else {
321321
0.0
322322
},
323323
)
324324
})
325325
.unwrap_or_default();
326326

327-
let max_possible_offset = (content_size - layout_size).max(Vec2::ZERO);
328-
let clamped_scroll_position = scroll_position.clamp(
329-
Vec2::ZERO,
330-
max_possible_offset * inverse_target_scale_factor,
331-
);
327+
let max_possible_offset =
328+
(content_size - layout_size + node.scrollbar_size).max(Vec2::ZERO);
329+
let clamped_scroll_position = scroll_position.clamp(Vec2::ZERO, max_possible_offset);
332330

333-
if clamped_scroll_position != scroll_position {
334-
commands
335-
.entity(entity)
336-
.insert(ScrollPosition(clamped_scroll_position));
337-
}
331+
let physical_scroll_position = clamped_scroll_position.floor();
338332

339-
let physical_scroll_position =
340-
(clamped_scroll_position / inverse_target_scale_factor).round();
333+
node.bypass_change_detection().scroll_position = physical_scroll_position;
341334

342335
for child_uinode in ui_children.iter_ui_children(entity) {
343336
update_uinode_geometry_recursive(
344-
commands,
345337
child_uinode,
346338
ui_surface,
347339
use_rounding,

crates/bevy_ui/src/ui_node.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ pub struct ComputedNode {
4242
///
4343
/// Automatically calculated by [`super::layout::ui_layout_system`].
4444
pub content_size: Vec2,
45+
/// Space allocated for scrollbars.
46+
///
47+
/// Automatically calculated by [`super::layout::ui_layout_system`].
48+
pub scrollbar_size: Vec2,
49+
/// Resolved offset of scrolled content
50+
///
51+
/// Automatically calculated by [`super::layout::ui_layout_system`].
52+
pub scroll_position: Vec2,
4553
/// The width of this node's outline.
4654
/// If this value is `Auto`, negative or `0.` then no outline will be rendered.
4755
/// Outline updates bypass change detection.
@@ -305,6 +313,8 @@ impl ComputedNode {
305313
stack_index: 0,
306314
size: Vec2::ZERO,
307315
content_size: Vec2::ZERO,
316+
scrollbar_size: Vec2::ZERO,
317+
scroll_position: Vec2::ZERO,
308318
outline_width: 0.,
309319
outline_offset: 0.,
310320
unrounded_size: Vec2::ZERO,
@@ -419,6 +429,9 @@ pub struct Node {
419429
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow>
420430
pub overflow: Overflow,
421431

432+
/// How much space in logical pixels should be reserved for scrollbars when overflow is set to scroll or auto on an axis.
433+
pub scrollbar_width: f32,
434+
422435
/// How the bounds of clipped content should be determined
423436
///
424437
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overflow-clip-margin>
@@ -703,6 +716,7 @@ impl Node {
703716
aspect_ratio: None,
704717
overflow: Overflow::DEFAULT,
705718
overflow_clip_margin: OverflowClipMargin::DEFAULT,
719+
scrollbar_width: 0.,
706720
row_gap: Val::ZERO,
707721
column_gap: Val::ZERO,
708722
grid_auto_flow: GridAutoFlow::DEFAULT,

crates/bevy_ui/src/update.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ fn update_clipping(
112112

113113
clip_rect.min.x += clip_inset.left;
114114
clip_rect.min.y += clip_inset.top;
115-
clip_rect.max.x -= clip_inset.right;
116-
clip_rect.max.y -= clip_inset.bottom;
115+
clip_rect.max.x -= clip_inset.right + computed_node.scrollbar_size.x;
116+
clip_rect.max.y -= clip_inset.bottom + computed_node.scrollbar_size.y;
117117

118118
clip_rect = clip_rect
119119
.inflate(node.overflow_clip_margin.margin.max(0.) / computed_node.inverse_scale_factor);

examples/README.md

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

examples/ui/drag_to_scroll.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! This example tests scale factor, dragging and scrolling
2+
3+
use bevy::color::palettes::css::RED;
4+
use bevy::prelude::*;
5+
6+
#[derive(Component)]
7+
struct DragNode;
8+
9+
#[derive(Component)]
10+
struct ScrollableNode;
11+
12+
#[derive(Component)]
13+
struct TileColor(Color);
14+
15+
fn main() {
16+
App::new()
17+
.add_plugins(DefaultPlugins)
18+
.add_systems(Startup, setup)
19+
.run();
20+
}
21+
22+
#[derive(Component)]
23+
struct ScrollStart(Vec2);
24+
25+
fn setup(mut commands: Commands) {
26+
let w = 60;
27+
let h = 40;
28+
29+
commands.spawn(Camera2d);
30+
commands.insert_resource(UiScale(0.5));
31+
32+
commands
33+
.spawn((
34+
Node {
35+
width: Val::Percent(100.),
36+
height: Val::Percent(100.),
37+
overflow: Overflow::scroll(),
38+
..Default::default()
39+
},
40+
ScrollPosition(Vec2::ZERO),
41+
ScrollableNode,
42+
ScrollStart(Vec2::ZERO),
43+
))
44+
.observe(
45+
|
46+
drag: On<Pointer<Drag>>,
47+
ui_scale: Res<UiScale>,
48+
mut scroll_position_query: Query<(
49+
&mut ScrollPosition,
50+
&ScrollStart),
51+
With<ScrollableNode>,
52+
>| {
53+
if let Ok((mut scroll_position, start)) = scroll_position_query.single_mut() {
54+
scroll_position.0 = (start.0 - drag.distance / ui_scale.0).max(Vec2::ZERO);
55+
}
56+
},
57+
)
58+
.observe(
59+
|
60+
on: On<Pointer<DragStart>>,
61+
mut scroll_position_query: Query<(
62+
&ComputedNode,
63+
&mut ScrollStart),
64+
With<ScrollableNode>,
65+
>| {
66+
if on.target() != on.original_target() {
67+
return;
68+
}
69+
if let Ok((computed_node, mut start)) = scroll_position_query.single_mut() {
70+
start.0 = computed_node.scroll_position * computed_node.inverse_scale_factor;
71+
}
72+
},
73+
)
74+
75+
.with_children(|commands| {
76+
commands
77+
.spawn(Node {
78+
display: Display::Grid,
79+
grid_template_rows: RepeatedGridTrack::px(w as i32, 100.),
80+
grid_template_columns: RepeatedGridTrack::px(h as i32, 100.),
81+
..Default::default()
82+
})
83+
.with_children(|commands| {
84+
for y in 0..h {
85+
for x in 0..w {
86+
let tile_color = if (x + y) % 2 == 1 {
87+
let hue = ((x as f32 / w as f32) * 270.0) + ((y as f32 / h as f32) * 90.0);
88+
Color::hsl(hue, 1., 0.5)
89+
} else {
90+
Color::BLACK
91+
};
92+
commands
93+
.spawn((
94+
Node {
95+
grid_row: GridPlacement::start(y + 1),
96+
grid_column: GridPlacement::start(x + 1),
97+
..Default::default()
98+
},
99+
Pickable {
100+
should_block_lower: false,
101+
is_hoverable: true,
102+
},
103+
TileColor(tile_color),
104+
BackgroundColor(tile_color),
105+
))
106+
.observe(|on_enter: On<Pointer<Over>>, mut query: Query<&mut BackgroundColor>, | {
107+
if let Ok(mut background_color) = query.get_mut(on_enter.target()) {
108+
background_color.0 = RED.into();
109+
}
110+
})
111+
.observe(|on_enter: On<Pointer<Out>>, mut query: Query<(&mut BackgroundColor, &TileColor)>,| {
112+
if let Ok((mut background_color, tile_color)) = query.get_mut(on_enter.target()) {
113+
background_color.0 = tile_color.0;
114+
}
115+
});
116+
}
117+
}
118+
});
119+
});
120+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: `ScrollPosition` now uses logical pixel units and is no longer overwritten during layout updates
3+
pull_requests: [20093]
4+
---
5+
`ScrollPosition` is no longer overwritten during layout updates. Instead the computed scroll position is stored in the new `scroll_position` field on `ComputedNode`.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
title: Automatically allocate space for scrollbars
3+
authors: ["@ickshonpe"]
4+
pull_requests: [20093]
5+
---
6+
7+
`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.

0 commit comments

Comments
 (0)