Skip to content

Commit 9be1c36

Browse files
viridiaickshonpe
andauthored
CoreScrollbar widget. (#19803)
# Objective Part of #19236 ## Demo ![image](https://github.com/user-attachments/assets/8607f672-de8f-4339-bdfc-817b39f32e3e) https://discord.com/channels/691052431525675048/743663673393938453/1387110701386039317 --------- Co-authored-by: ickshonpe <david.curthoys@googlemail.com>
1 parent 7090241 commit 9be1c36

File tree

6 files changed

+568
-1
lines changed

6 files changed

+568
-1
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4547,6 +4547,17 @@ description = "Demonstrates use of core (headless) widgets in Bevy UI, with Obse
45474547
category = "UI (User Interface)"
45484548
wasm = true
45494549

4550+
[[example]]
4551+
name = "scrollbars"
4552+
path = "examples/ui/scrollbars.rs"
4553+
doc-scrape-examples = true
4554+
4555+
[package.metadata.example.scrollbars]
4556+
name = "Scrollbars"
4557+
description = "Demonstrates use of core scrollbar in Bevy UI"
4558+
category = "UI (User Interface)"
4559+
wasm = true
4560+
45504561
[[example]]
45514562
name = "feathers"
45524563
path = "examples/ui/feathers.rs"
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
use bevy_app::{App, Plugin, PostUpdate};
2+
use bevy_ecs::{
3+
component::Component,
4+
entity::Entity,
5+
hierarchy::{ChildOf, Children},
6+
observer::On,
7+
query::{With, Without},
8+
system::{Query, Res},
9+
};
10+
use bevy_math::Vec2;
11+
use bevy_picking::events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press};
12+
use bevy_ui::{
13+
ComputedNode, ComputedNodeTarget, Node, ScrollPosition, UiGlobalTransform, UiScale, Val,
14+
};
15+
16+
/// Used to select the orientation of a scrollbar, slider, or other oriented control.
17+
// TODO: Move this to a more central place.
18+
#[derive(Debug, Default, Clone, Copy, PartialEq)]
19+
pub enum ControlOrientation {
20+
/// Horizontal orientation (stretching from left to right)
21+
Horizontal,
22+
/// Vertical orientation (stretching from top to bottom)
23+
#[default]
24+
Vertical,
25+
}
26+
27+
/// A headless scrollbar widget, which can be used to build custom scrollbars.
28+
///
29+
/// Scrollbars operate differently than the other core widgets in a number of respects.
30+
///
31+
/// Unlike sliders, scrollbars don't have an [`AccessibilityNode`](bevy_a11y::AccessibilityNode)
32+
/// component, nor can they have keyboard focus. This is because scrollbars are usually used in
33+
/// conjunction with a scrollable container, which is itself accessible and focusable. This also
34+
/// means that scrollbars don't accept keyboard events, which is also the responsibility of the
35+
/// scrollable container.
36+
///
37+
/// Scrollbars don't emit notification events; instead they modify the scroll position of the target
38+
/// entity directly.
39+
///
40+
/// A scrollbar can have any number of child entities, but one entity must be the scrollbar thumb,
41+
/// which is marked with the [`CoreScrollbarThumb`] component. Other children are ignored. The core
42+
/// scrollbar will directly update the position and size of this entity; the application is free to
43+
/// set any other style properties as desired.
44+
///
45+
/// The application is free to position the scrollbars relative to the scrolling container however
46+
/// it wants: it can overlay them on top of the scrolling content, or use a grid layout to displace
47+
/// the content to make room for the scrollbars.
48+
#[derive(Component, Debug)]
49+
pub struct CoreScrollbar {
50+
/// Entity being scrolled.
51+
pub target: Entity,
52+
/// Whether the scrollbar is vertical or horizontal.
53+
pub orientation: ControlOrientation,
54+
/// Minimum length of the scrollbar thumb, in pixel units, in the direction parallel to the main
55+
/// scrollbar axis. The scrollbar will resize the thumb entity based on the proportion of
56+
/// visible size to content size, but no smaller than this. This prevents the thumb from
57+
/// disappearing in cases where the ratio of content size to visible size is large.
58+
pub min_thumb_length: f32,
59+
}
60+
61+
/// Marker component to indicate that the entity is a scrollbar thumb (the moving, draggable part of
62+
/// the scrollbar). This should be a child of the scrollbar entity.
63+
#[derive(Component, Debug)]
64+
#[require(CoreScrollbarDragState)]
65+
pub struct CoreScrollbarThumb;
66+
67+
impl CoreScrollbar {
68+
/// Construct a new scrollbar.
69+
///
70+
/// # Arguments
71+
///
72+
/// * `target` - The scrollable entity that this scrollbar will control.
73+
/// * `orientation` - The orientation of the scrollbar (horizontal or vertical).
74+
/// * `min_thumb_length` - The minimum size of the scrollbar's thumb, in pixels.
75+
pub fn new(target: Entity, orientation: ControlOrientation, min_thumb_length: f32) -> Self {
76+
Self {
77+
target,
78+
orientation,
79+
min_thumb_length,
80+
}
81+
}
82+
}
83+
84+
/// Component used to manage the state of a scrollbar during dragging. This component is
85+
/// inserted on the thumb entity.
86+
#[derive(Component, Default)]
87+
pub struct CoreScrollbarDragState {
88+
/// Whether the scrollbar is currently being dragged.
89+
pub dragging: bool,
90+
/// The value of the scrollbar when dragging started.
91+
drag_origin: f32,
92+
}
93+
94+
fn scrollbar_on_pointer_down(
95+
mut ev: On<Pointer<Press>>,
96+
q_thumb: Query<&ChildOf, With<CoreScrollbarThumb>>,
97+
mut q_scrollbar: Query<(
98+
&CoreScrollbar,
99+
&ComputedNode,
100+
&ComputedNodeTarget,
101+
&UiGlobalTransform,
102+
)>,
103+
mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<CoreScrollbar>>,
104+
ui_scale: Res<UiScale>,
105+
) {
106+
if q_thumb.contains(ev.target()) {
107+
// If they click on the thumb, do nothing. This will be handled by the drag event.
108+
ev.propagate(false);
109+
} else if let Ok((scrollbar, node, node_target, transform)) = q_scrollbar.get_mut(ev.target()) {
110+
// If they click on the scrollbar track, page up or down.
111+
ev.propagate(false);
112+
113+
// Convert to widget-local coordinates.
114+
let local_pos = transform.try_inverse().unwrap().transform_point2(
115+
ev.event().pointer_location.position * node_target.scale_factor() / ui_scale.0,
116+
) + node.size() * 0.5;
117+
118+
// Bail if we don't find the target entity.
119+
let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else {
120+
return;
121+
};
122+
123+
// Convert the click coordinates into a scroll position. If it's greater than the
124+
// current scroll position, scroll forward by one step (visible size) otherwise scroll
125+
// back.
126+
let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor;
127+
let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor;
128+
let max_range = (content_size - visible_size).max(Vec2::ZERO);
129+
130+
fn adjust_scroll_pos(scroll_pos: &mut f32, click_pos: f32, step: f32, range: f32) {
131+
*scroll_pos =
132+
(*scroll_pos + if click_pos > *scroll_pos { step } else { -step }).clamp(0., range);
133+
}
134+
135+
match scrollbar.orientation {
136+
ControlOrientation::Horizontal => {
137+
if node.size().x > 0. {
138+
let click_pos = local_pos.x * content_size.x / node.size().x;
139+
adjust_scroll_pos(
140+
&mut scroll_pos.offset_x,
141+
click_pos,
142+
visible_size.x,
143+
max_range.x,
144+
);
145+
}
146+
}
147+
ControlOrientation::Vertical => {
148+
if node.size().y > 0. {
149+
let click_pos = local_pos.y * content_size.y / node.size().y;
150+
adjust_scroll_pos(
151+
&mut scroll_pos.offset_y,
152+
click_pos,
153+
visible_size.y,
154+
max_range.y,
155+
);
156+
}
157+
}
158+
}
159+
}
160+
}
161+
162+
fn scrollbar_on_drag_start(
163+
mut ev: On<Pointer<DragStart>>,
164+
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
165+
q_scrollbar: Query<&CoreScrollbar>,
166+
q_scroll_area: Query<&ScrollPosition>,
167+
) {
168+
if let Ok((ChildOf(thumb_parent), mut drag)) = q_thumb.get_mut(ev.target()) {
169+
ev.propagate(false);
170+
if let Ok(scrollbar) = q_scrollbar.get(*thumb_parent) {
171+
if let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) {
172+
drag.dragging = true;
173+
drag.drag_origin = match scrollbar.orientation {
174+
ControlOrientation::Horizontal => scroll_area.offset_x,
175+
ControlOrientation::Vertical => scroll_area.offset_y,
176+
};
177+
}
178+
}
179+
}
180+
}
181+
182+
fn scrollbar_on_drag(
183+
mut ev: On<Pointer<Drag>>,
184+
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
185+
mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar)>,
186+
mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<CoreScrollbar>>,
187+
ui_scale: Res<UiScale>,
188+
) {
189+
if let Ok((ChildOf(thumb_parent), drag)) = q_thumb.get_mut(ev.target()) {
190+
if let Ok((node, scrollbar)) = q_scrollbar.get_mut(*thumb_parent) {
191+
ev.propagate(false);
192+
let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target)
193+
else {
194+
return;
195+
};
196+
197+
if drag.dragging {
198+
let distance = ev.event().distance / ui_scale.0;
199+
let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor;
200+
let content_size =
201+
scroll_content.content_size() * scroll_content.inverse_scale_factor;
202+
let scrollbar_size = (node.size() * node.inverse_scale_factor).max(Vec2::ONE);
203+
204+
match scrollbar.orientation {
205+
ControlOrientation::Horizontal => {
206+
let range = (content_size.x - visible_size.x).max(0.);
207+
scroll_pos.offset_x = (drag.drag_origin
208+
+ (distance.x * content_size.x) / scrollbar_size.x)
209+
.clamp(0., range);
210+
}
211+
ControlOrientation::Vertical => {
212+
let range = (content_size.y - visible_size.y).max(0.);
213+
scroll_pos.offset_y = (drag.drag_origin
214+
+ (distance.y * content_size.y) / scrollbar_size.y)
215+
.clamp(0., range);
216+
}
217+
};
218+
}
219+
}
220+
}
221+
}
222+
223+
fn scrollbar_on_drag_end(
224+
mut ev: On<Pointer<DragEnd>>,
225+
mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
226+
) {
227+
if let Ok(mut drag) = q_thumb.get_mut(ev.target()) {
228+
ev.propagate(false);
229+
if drag.dragging {
230+
drag.dragging = false;
231+
}
232+
}
233+
}
234+
235+
fn scrollbar_on_drag_cancel(
236+
mut ev: On<Pointer<Cancel>>,
237+
mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
238+
) {
239+
if let Ok(mut drag) = q_thumb.get_mut(ev.target()) {
240+
ev.propagate(false);
241+
if drag.dragging {
242+
drag.dragging = false;
243+
}
244+
}
245+
}
246+
247+
fn update_scrollbar_thumb(
248+
q_scroll_area: Query<(&ScrollPosition, &ComputedNode)>,
249+
q_scrollbar: Query<(&CoreScrollbar, &ComputedNode, &Children)>,
250+
mut q_thumb: Query<&mut Node, With<CoreScrollbarThumb>>,
251+
) {
252+
for (scrollbar, scrollbar_node, children) in q_scrollbar.iter() {
253+
let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) else {
254+
continue;
255+
};
256+
257+
// Size of the visible scrolling area.
258+
let visible_size = scroll_area.1.size() * scroll_area.1.inverse_scale_factor;
259+
260+
// Size of the scrolling content.
261+
let content_size = scroll_area.1.content_size() * scroll_area.1.inverse_scale_factor;
262+
263+
// Length of the scrollbar track.
264+
let track_length = scrollbar_node.size() * scrollbar_node.inverse_scale_factor;
265+
266+
fn size_and_pos(
267+
content_size: f32,
268+
visible_size: f32,
269+
track_length: f32,
270+
min_size: f32,
271+
offset: f32,
272+
) -> (f32, f32) {
273+
let thumb_size = if content_size > visible_size {
274+
(track_length * visible_size / content_size)
275+
.max(min_size)
276+
.min(track_length)
277+
} else {
278+
track_length
279+
};
280+
281+
let thumb_pos = if content_size > visible_size {
282+
offset * (track_length - thumb_size) / (content_size - visible_size)
283+
} else {
284+
0.
285+
};
286+
287+
(thumb_size, thumb_pos)
288+
}
289+
290+
for child in children {
291+
if let Ok(mut thumb) = q_thumb.get_mut(*child) {
292+
match scrollbar.orientation {
293+
ControlOrientation::Horizontal => {
294+
let (thumb_size, thumb_pos) = size_and_pos(
295+
content_size.x,
296+
visible_size.x,
297+
track_length.x,
298+
scrollbar.min_thumb_length,
299+
scroll_area.0.offset_x,
300+
);
301+
302+
thumb.top = Val::Px(0.);
303+
thumb.bottom = Val::Px(0.);
304+
thumb.left = Val::Px(thumb_pos);
305+
thumb.width = Val::Px(thumb_size);
306+
}
307+
ControlOrientation::Vertical => {
308+
let (thumb_size, thumb_pos) = size_and_pos(
309+
content_size.y,
310+
visible_size.y,
311+
track_length.y,
312+
scrollbar.min_thumb_length,
313+
scroll_area.0.offset_y,
314+
);
315+
316+
thumb.left = Val::Px(0.);
317+
thumb.right = Val::Px(0.);
318+
thumb.top = Val::Px(thumb_pos);
319+
thumb.height = Val::Px(thumb_size);
320+
}
321+
};
322+
}
323+
}
324+
}
325+
}
326+
327+
/// Plugin that adds the observers for the [`CoreScrollbar`] widget.
328+
pub struct CoreScrollbarPlugin;
329+
330+
impl Plugin for CoreScrollbarPlugin {
331+
fn build(&self, app: &mut App) {
332+
app.add_observer(scrollbar_on_pointer_down)
333+
.add_observer(scrollbar_on_drag_start)
334+
.add_observer(scrollbar_on_drag_end)
335+
.add_observer(scrollbar_on_drag_cancel)
336+
.add_observer(scrollbar_on_drag)
337+
.add_systems(PostUpdate, update_scrollbar_thumb);
338+
}
339+
}

crates/bevy_core_widgets/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,18 @@
1717
mod core_button;
1818
mod core_checkbox;
1919
mod core_radio;
20+
mod core_scrollbar;
2021
mod core_slider;
2122

2223
use bevy_app::{App, Plugin};
2324

2425
pub use core_button::{CoreButton, CoreButtonPlugin};
2526
pub use core_checkbox::{CoreCheckbox, CoreCheckboxPlugin, SetChecked, ToggleChecked};
2627
pub use core_radio::{CoreRadio, CoreRadioGroup, CoreRadioGroupPlugin};
28+
pub use core_scrollbar::{
29+
ControlOrientation, CoreScrollbar, CoreScrollbarDragState, CoreScrollbarPlugin,
30+
CoreScrollbarThumb,
31+
};
2732
pub use core_slider::{
2833
CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue,
2934
SliderRange, SliderStep, SliderValue, TrackClick,
@@ -39,6 +44,7 @@ impl Plugin for CoreWidgetsPlugin {
3944
CoreButtonPlugin,
4045
CoreCheckboxPlugin,
4146
CoreRadioGroupPlugin,
47+
CoreScrollbarPlugin,
4248
CoreSliderPlugin,
4349
));
4450
}

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ Example | Description
563563
[Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component
564564
[Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world
565565
[Scroll](../examples/ui/scroll.rs) | Demonstrates scrolling UI containers
566+
[Scrollbars](../examples/ui/scrollbars.rs) | Demonstrates use of core scrollbar in Bevy UI
566567
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
567568
[Stacked Gradients](../examples/ui/stacked_gradients.rs) | An example demonstrating stacked gradients
568569
[Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation between UI elements

0 commit comments

Comments
 (0)