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>
This commit is contained in:
Talin 2025-06-30 16:02:03 -07:00 committed by GitHub
parent 70902413b2
commit 9be1c36391
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 568 additions and 1 deletions

View File

@ -4547,6 +4547,17 @@ description = "Demonstrates use of core (headless) widgets in Bevy UI, with Obse
category = "UI (User Interface)"
wasm = true
[[example]]
name = "scrollbars"
path = "examples/ui/scrollbars.rs"
doc-scrape-examples = true
[package.metadata.example.scrollbars]
name = "Scrollbars"
description = "Demonstrates use of core scrollbar in Bevy UI"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "feathers"
path = "examples/ui/feathers.rs"

View File

@ -0,0 +1,339 @@
use bevy_app::{App, Plugin, PostUpdate};
use bevy_ecs::{
component::Component,
entity::Entity,
hierarchy::{ChildOf, Children},
observer::On,
query::{With, Without},
system::{Query, Res},
};
use bevy_math::Vec2;
use bevy_picking::events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press};
use bevy_ui::{
ComputedNode, ComputedNodeTarget, Node, ScrollPosition, UiGlobalTransform, UiScale, Val,
};
/// Used to select the orientation of a scrollbar, slider, or other oriented control.
// TODO: Move this to a more central place.
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub enum ControlOrientation {
/// Horizontal orientation (stretching from left to right)
Horizontal,
/// Vertical orientation (stretching from top to bottom)
#[default]
Vertical,
}
/// A headless scrollbar widget, which can be used to build custom scrollbars.
///
/// Scrollbars operate differently than the other core widgets in a number of respects.
///
/// Unlike sliders, scrollbars don't have an [`AccessibilityNode`](bevy_a11y::AccessibilityNode)
/// component, nor can they have keyboard focus. This is because scrollbars are usually used in
/// conjunction with a scrollable container, which is itself accessible and focusable. This also
/// means that scrollbars don't accept keyboard events, which is also the responsibility of the
/// scrollable container.
///
/// Scrollbars don't emit notification events; instead they modify the scroll position of the target
/// entity directly.
///
/// A scrollbar can have any number of child entities, but one entity must be the scrollbar thumb,
/// which is marked with the [`CoreScrollbarThumb`] component. Other children are ignored. The core
/// scrollbar will directly update the position and size of this entity; the application is free to
/// set any other style properties as desired.
///
/// The application is free to position the scrollbars relative to the scrolling container however
/// it wants: it can overlay them on top of the scrolling content, or use a grid layout to displace
/// the content to make room for the scrollbars.
#[derive(Component, Debug)]
pub struct CoreScrollbar {
/// Entity being scrolled.
pub target: Entity,
/// Whether the scrollbar is vertical or horizontal.
pub orientation: ControlOrientation,
/// Minimum length of the scrollbar thumb, in pixel units, in the direction parallel to the main
/// scrollbar axis. The scrollbar will resize the thumb entity based on the proportion of
/// visible size to content size, but no smaller than this. This prevents the thumb from
/// disappearing in cases where the ratio of content size to visible size is large.
pub min_thumb_length: f32,
}
/// Marker component to indicate that the entity is a scrollbar thumb (the moving, draggable part of
/// the scrollbar). This should be a child of the scrollbar entity.
#[derive(Component, Debug)]
#[require(CoreScrollbarDragState)]
pub struct CoreScrollbarThumb;
impl CoreScrollbar {
/// Construct a new scrollbar.
///
/// # Arguments
///
/// * `target` - The scrollable entity that this scrollbar will control.
/// * `orientation` - The orientation of the scrollbar (horizontal or vertical).
/// * `min_thumb_length` - The minimum size of the scrollbar's thumb, in pixels.
pub fn new(target: Entity, orientation: ControlOrientation, min_thumb_length: f32) -> Self {
Self {
target,
orientation,
min_thumb_length,
}
}
}
/// Component used to manage the state of a scrollbar during dragging. This component is
/// inserted on the thumb entity.
#[derive(Component, Default)]
pub struct CoreScrollbarDragState {
/// Whether the scrollbar is currently being dragged.
pub dragging: bool,
/// The value of the scrollbar when dragging started.
drag_origin: f32,
}
fn scrollbar_on_pointer_down(
mut ev: On<Pointer<Press>>,
q_thumb: Query<&ChildOf, With<CoreScrollbarThumb>>,
mut q_scrollbar: Query<(
&CoreScrollbar,
&ComputedNode,
&ComputedNodeTarget,
&UiGlobalTransform,
)>,
mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<CoreScrollbar>>,
ui_scale: Res<UiScale>,
) {
if q_thumb.contains(ev.target()) {
// If they click on the thumb, do nothing. This will be handled by the drag event.
ev.propagate(false);
} else if let Ok((scrollbar, node, node_target, transform)) = q_scrollbar.get_mut(ev.target()) {
// If they click on the scrollbar track, page up or down.
ev.propagate(false);
// Convert to widget-local coordinates.
let local_pos = transform.try_inverse().unwrap().transform_point2(
ev.event().pointer_location.position * node_target.scale_factor() / ui_scale.0,
) + node.size() * 0.5;
// Bail if we don't find the target entity.
let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else {
return;
};
// Convert the click coordinates into a scroll position. If it's greater than the
// current scroll position, scroll forward by one step (visible size) otherwise scroll
// back.
let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor;
let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor;
let max_range = (content_size - visible_size).max(Vec2::ZERO);
fn adjust_scroll_pos(scroll_pos: &mut f32, click_pos: f32, step: f32, range: f32) {
*scroll_pos =
(*scroll_pos + if click_pos > *scroll_pos { step } else { -step }).clamp(0., range);
}
match scrollbar.orientation {
ControlOrientation::Horizontal => {
if node.size().x > 0. {
let click_pos = local_pos.x * content_size.x / node.size().x;
adjust_scroll_pos(
&mut scroll_pos.offset_x,
click_pos,
visible_size.x,
max_range.x,
);
}
}
ControlOrientation::Vertical => {
if node.size().y > 0. {
let click_pos = local_pos.y * content_size.y / node.size().y;
adjust_scroll_pos(
&mut scroll_pos.offset_y,
click_pos,
visible_size.y,
max_range.y,
);
}
}
}
}
}
fn scrollbar_on_drag_start(
mut ev: On<Pointer<DragStart>>,
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
q_scrollbar: Query<&CoreScrollbar>,
q_scroll_area: Query<&ScrollPosition>,
) {
if let Ok((ChildOf(thumb_parent), mut drag)) = q_thumb.get_mut(ev.target()) {
ev.propagate(false);
if let Ok(scrollbar) = q_scrollbar.get(*thumb_parent) {
if let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) {
drag.dragging = true;
drag.drag_origin = match scrollbar.orientation {
ControlOrientation::Horizontal => scroll_area.offset_x,
ControlOrientation::Vertical => scroll_area.offset_y,
};
}
}
}
}
fn scrollbar_on_drag(
mut ev: On<Pointer<Drag>>,
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar)>,
mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<CoreScrollbar>>,
ui_scale: Res<UiScale>,
) {
if let Ok((ChildOf(thumb_parent), drag)) = q_thumb.get_mut(ev.target()) {
if let Ok((node, scrollbar)) = q_scrollbar.get_mut(*thumb_parent) {
ev.propagate(false);
let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target)
else {
return;
};
if drag.dragging {
let distance = ev.event().distance / ui_scale.0;
let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor;
let content_size =
scroll_content.content_size() * scroll_content.inverse_scale_factor;
let scrollbar_size = (node.size() * node.inverse_scale_factor).max(Vec2::ONE);
match scrollbar.orientation {
ControlOrientation::Horizontal => {
let range = (content_size.x - visible_size.x).max(0.);
scroll_pos.offset_x = (drag.drag_origin
+ (distance.x * content_size.x) / scrollbar_size.x)
.clamp(0., range);
}
ControlOrientation::Vertical => {
let range = (content_size.y - visible_size.y).max(0.);
scroll_pos.offset_y = (drag.drag_origin
+ (distance.y * content_size.y) / scrollbar_size.y)
.clamp(0., range);
}
};
}
}
}
}
fn scrollbar_on_drag_end(
mut ev: On<Pointer<DragEnd>>,
mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
) {
if let Ok(mut drag) = q_thumb.get_mut(ev.target()) {
ev.propagate(false);
if drag.dragging {
drag.dragging = false;
}
}
}
fn scrollbar_on_drag_cancel(
mut ev: On<Pointer<Cancel>>,
mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
) {
if let Ok(mut drag) = q_thumb.get_mut(ev.target()) {
ev.propagate(false);
if drag.dragging {
drag.dragging = false;
}
}
}
fn update_scrollbar_thumb(
q_scroll_area: Query<(&ScrollPosition, &ComputedNode)>,
q_scrollbar: Query<(&CoreScrollbar, &ComputedNode, &Children)>,
mut q_thumb: Query<&mut Node, With<CoreScrollbarThumb>>,
) {
for (scrollbar, scrollbar_node, children) in q_scrollbar.iter() {
let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) else {
continue;
};
// Size of the visible scrolling area.
let visible_size = scroll_area.1.size() * scroll_area.1.inverse_scale_factor;
// Size of the scrolling content.
let content_size = scroll_area.1.content_size() * scroll_area.1.inverse_scale_factor;
// Length of the scrollbar track.
let track_length = scrollbar_node.size() * scrollbar_node.inverse_scale_factor;
fn size_and_pos(
content_size: f32,
visible_size: f32,
track_length: f32,
min_size: f32,
offset: f32,
) -> (f32, f32) {
let thumb_size = if content_size > visible_size {
(track_length * visible_size / content_size)
.max(min_size)
.min(track_length)
} else {
track_length
};
let thumb_pos = if content_size > visible_size {
offset * (track_length - thumb_size) / (content_size - visible_size)
} else {
0.
};
(thumb_size, thumb_pos)
}
for child in children {
if let Ok(mut thumb) = q_thumb.get_mut(*child) {
match scrollbar.orientation {
ControlOrientation::Horizontal => {
let (thumb_size, thumb_pos) = size_and_pos(
content_size.x,
visible_size.x,
track_length.x,
scrollbar.min_thumb_length,
scroll_area.0.offset_x,
);
thumb.top = Val::Px(0.);
thumb.bottom = Val::Px(0.);
thumb.left = Val::Px(thumb_pos);
thumb.width = Val::Px(thumb_size);
}
ControlOrientation::Vertical => {
let (thumb_size, thumb_pos) = size_and_pos(
content_size.y,
visible_size.y,
track_length.y,
scrollbar.min_thumb_length,
scroll_area.0.offset_y,
);
thumb.left = Val::Px(0.);
thumb.right = Val::Px(0.);
thumb.top = Val::Px(thumb_pos);
thumb.height = Val::Px(thumb_size);
}
};
}
}
}
}
/// Plugin that adds the observers for the [`CoreScrollbar`] widget.
pub struct CoreScrollbarPlugin;
impl Plugin for CoreScrollbarPlugin {
fn build(&self, app: &mut App) {
app.add_observer(scrollbar_on_pointer_down)
.add_observer(scrollbar_on_drag_start)
.add_observer(scrollbar_on_drag_end)
.add_observer(scrollbar_on_drag_cancel)
.add_observer(scrollbar_on_drag)
.add_systems(PostUpdate, update_scrollbar_thumb);
}
}

View File

@ -17,6 +17,7 @@
mod core_button;
mod core_checkbox;
mod core_radio;
mod core_scrollbar;
mod core_slider;
use bevy_app::{App, Plugin};
@ -24,6 +25,10 @@ use bevy_app::{App, Plugin};
pub use core_button::{CoreButton, CoreButtonPlugin};
pub use core_checkbox::{CoreCheckbox, CoreCheckboxPlugin, SetChecked, ToggleChecked};
pub use core_radio::{CoreRadio, CoreRadioGroup, CoreRadioGroupPlugin};
pub use core_scrollbar::{
ControlOrientation, CoreScrollbar, CoreScrollbarDragState, CoreScrollbarPlugin,
CoreScrollbarThumb,
};
pub use core_slider::{
CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue,
SliderRange, SliderStep, SliderValue, TrackClick,
@ -39,6 +44,7 @@ impl Plugin for CoreWidgetsPlugin {
CoreButtonPlugin,
CoreCheckboxPlugin,
CoreRadioGroupPlugin,
CoreScrollbarPlugin,
CoreSliderPlugin,
));
}

View File

@ -563,6 +563,7 @@ Example | Description
[Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component
[Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world
[Scroll](../examples/ui/scroll.rs) | Demonstrates scrolling UI containers
[Scrollbars](../examples/ui/scrollbars.rs) | Demonstrates use of core scrollbar in Bevy UI
[Size Constraints](../examples/ui/size_constraints.rs) | Demonstrates how the to use the size constraints to control the size of a UI node.
[Stacked Gradients](../examples/ui/stacked_gradients.rs) | An example demonstrating stacked gradients
[Tab Navigation](../examples/ui/tab_navigation.rs) | Demonstration of Tab Navigation between UI elements

208
examples/ui/scrollbars.rs Normal file
View File

@ -0,0 +1,208 @@
//! Demonstrations of scrolling and scrollbars.
use bevy::{
core_widgets::{
ControlOrientation, CoreScrollbar, CoreScrollbarDragState, CoreScrollbarPlugin,
CoreScrollbarThumb,
},
ecs::{relationship::RelatedSpawner, spawn::SpawnWith},
input_focus::{
tab_navigation::{TabGroup, TabNavigationPlugin},
InputDispatchPlugin,
},
picking::hover::Hovered,
prelude::*,
};
fn main() {
App::new()
.add_plugins((
DefaultPlugins,
CoreScrollbarPlugin,
InputDispatchPlugin,
TabNavigationPlugin,
))
.insert_resource(UiScale(1.25))
.add_systems(Startup, setup_view_root)
.add_systems(Update, update_scrollbar_thumb)
.run();
}
fn setup_view_root(mut commands: Commands) {
let camera = commands.spawn((Camera::default(), Camera2d)).id();
commands.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
position_type: PositionType::Absolute,
left: Val::Px(0.),
top: Val::Px(0.),
right: Val::Px(0.),
bottom: Val::Px(0.),
padding: UiRect::all(Val::Px(3.)),
row_gap: Val::Px(6.),
..Default::default()
},
BackgroundColor(Color::srgb(0.1, 0.1, 0.1)),
UiTargetCamera(camera),
TabGroup::default(),
Children::spawn((Spawn(Text::new("Scrolling")), Spawn(scroll_area_demo()))),
));
}
/// Create a scrolling area.
///
/// The "scroll area" is a container that can be scrolled. It has a nested structure which is
/// three levels deep:
/// - The outermost node is a grid that contains the scroll area and the scrollbars.
/// - The scroll area is a flex container that contains the scrollable content. This
/// is the element that has the `overflow: scroll` property.
/// - The scrollable content consists of the elements actually displayed in the scrolling area.
fn scroll_area_demo() -> impl Bundle {
(
// Frame element which contains the scroll area and scrollbars.
Node {
display: Display::Grid,
width: Val::Px(200.0),
height: Val::Px(150.0),
grid_template_columns: vec![RepeatedGridTrack::flex(1, 1.), RepeatedGridTrack::auto(1)],
grid_template_rows: vec![RepeatedGridTrack::flex(1, 1.), RepeatedGridTrack::auto(1)],
row_gap: Val::Px(2.0),
column_gap: Val::Px(2.0),
..default()
},
Children::spawn((SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
// The actual scrolling area.
// Note that we're using `SpawnWith` here because we need to get the entity id of the
// scroll area in order to set the target of the scrollbars.
let scroll_area_id = parent
.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(4.0)),
overflow: Overflow::scroll(),
..default()
},
BackgroundColor(colors::GRAY1.into()),
ScrollPosition {
offset_x: 0.0,
offset_y: 10.0,
},
Children::spawn((
// The actual content of the scrolling area
Spawn(text_row("Alpha Wolf")),
Spawn(text_row("Beta Blocker")),
Spawn(text_row("Delta Sleep")),
Spawn(text_row("Gamma Ray")),
Spawn(text_row("Epsilon Eridani")),
Spawn(text_row("Zeta Function")),
Spawn(text_row("Lambda Calculus")),
Spawn(text_row("Nu Metal")),
Spawn(text_row("Pi Day")),
Spawn(text_row("Chi Pants")),
Spawn(text_row("Psi Powers")),
Spawn(text_row("Omega Fatty Acid")),
)),
))
.id();
// Vertical scrollbar
parent.spawn((
Node {
min_width: Val::Px(8.0),
grid_row: GridPlacement::start(1),
grid_column: GridPlacement::start(2),
..default()
},
CoreScrollbar {
orientation: ControlOrientation::Vertical,
target: scroll_area_id,
min_thumb_length: 8.0,
},
Children::spawn(Spawn((
Node {
position_type: PositionType::Absolute,
..default()
},
Hovered::default(),
BackgroundColor(colors::GRAY2.into()),
BorderRadius::all(Val::Px(4.0)),
CoreScrollbarThumb,
))),
));
// Horizontal scrollbar
parent.spawn((
Node {
min_height: Val::Px(8.0),
grid_row: GridPlacement::start(2),
grid_column: GridPlacement::start(1),
..default()
},
CoreScrollbar {
orientation: ControlOrientation::Horizontal,
target: scroll_area_id,
min_thumb_length: 8.0,
},
Children::spawn(Spawn((
Node {
position_type: PositionType::Absolute,
..default()
},
Hovered::default(),
BackgroundColor(colors::GRAY2.into()),
BorderRadius::all(Val::Px(4.0)),
CoreScrollbarThumb,
))),
));
}),)),
)
}
/// Create a list row
fn text_row(caption: &str) -> impl Bundle {
(
Text::new(caption),
TextFont {
font_size: 14.0,
..default()
},
)
}
// Update the color of the scrollbar thumb.
fn update_scrollbar_thumb(
mut q_thumb: Query<
(&mut BackgroundColor, &Hovered, &CoreScrollbarDragState),
(
With<CoreScrollbarThumb>,
Or<(Changed<Hovered>, Changed<CoreScrollbarDragState>)>,
),
>,
) {
for (mut thumb_bg, Hovered(is_hovering), drag) in q_thumb.iter_mut() {
let color: Color = if *is_hovering || drag.dragging {
// If hovering, use a lighter color
colors::GRAY3
} else {
// Default color for the slider
colors::GRAY2
}
.into();
if thumb_bg.0 != color {
// Update the color of the thumb
thumb_bg.0 = color;
}
}
}
mod colors {
use bevy::color::Srgba;
pub const GRAY1: Srgba = Srgba::new(0.224, 0.224, 0.243, 1.0);
pub const GRAY2: Srgba = Srgba::new(0.486, 0.486, 0.529, 1.0);
pub const GRAY3: Srgba = Srgba::new(1.0, 1.0, 1.0, 1.0);
}

View File

@ -1,7 +1,7 @@
---
title: Headless Widgets
authors: ["@viridia"]
pull_requests: [19366, 19584, 19665, 19778]
pull_requests: [19366, 19584, 19665, 19778, 19803]
---
Bevy's `Button` and `Interaction` components have been around for a long time. Unfortunately
@ -34,7 +34,9 @@ sliders, checkboxes and radio buttons.
- `CoreButton` is a push button. It emits an activation event when clicked.
- `CoreSlider` is a standard slider, which lets you edit an `f32` value in a given range.
- `CoreScrollbar` can be used to implement scrollbars.
- `CoreCheckbox` can be used for checkboxes and toggle switches.
- `CoreRadio` and `CoreRadioGroup` can be used for radio buttons.
## Widget Interaction States