From 9be1c363918e8be29dfea00f433ede381e38814d Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 30 Jun 2025 16:02:03 -0700 Subject: [PATCH] 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 --- Cargo.toml | 11 + .../bevy_core_widgets/src/core_scrollbar.rs | 339 ++++++++++++++++++ crates/bevy_core_widgets/src/lib.rs | 6 + examples/README.md | 1 + examples/ui/scrollbars.rs | 208 +++++++++++ .../release-notes/headless-widgets.md | 4 +- 6 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 crates/bevy_core_widgets/src/core_scrollbar.rs create mode 100644 examples/ui/scrollbars.rs diff --git a/Cargo.toml b/Cargo.toml index 173d9e1f03..6bc73fbd2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs new file mode 100644 index 0000000000..2d0fd49fb6 --- /dev/null +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -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>, + q_thumb: Query<&ChildOf, With>, + mut q_scrollbar: Query<( + &CoreScrollbar, + &ComputedNode, + &ComputedNodeTarget, + &UiGlobalTransform, + )>, + mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without>, + ui_scale: Res, +) { + 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>, + mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With>, + 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>, + mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With>, + mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar)>, + mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without>, + ui_scale: Res, +) { + 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>, + mut q_thumb: Query<&mut CoreScrollbarDragState, With>, +) { + 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>, + mut q_thumb: Query<&mut CoreScrollbarDragState, With>, +) { + 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>, +) { + 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); + } +} diff --git a/crates/bevy_core_widgets/src/lib.rs b/crates/bevy_core_widgets/src/lib.rs index ef9f3db51c..a0ddfa5eb8 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -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, )); } diff --git a/examples/README.md b/examples/README.md index 31d777afed..993299dfc6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/ui/scrollbars.rs b/examples/ui/scrollbars.rs new file mode 100644 index 0000000000..2726df12fb --- /dev/null +++ b/examples/ui/scrollbars.rs @@ -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| { + // 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, + Or<(Changed, Changed)>, + ), + >, +) { + 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); +} diff --git a/release-content/release-notes/headless-widgets.md b/release-content/release-notes/headless-widgets.md index e28c44ee9e..bb0398b43c 100644 --- a/release-content/release-notes/headless-widgets.md +++ b/release-content/release-notes/headless-widgets.md @@ -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