From 236a5bfee4a1c19c49fb2776880dde129a599bdb Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 24 Jun 2025 09:19:06 -0700 Subject: [PATCH 01/13] CoreScrollbar widget. --- Cargo.toml | 11 + .../bevy_core_widgets/src/core_scrollbar.rs | 311 ++++++++++++++++++ crates/bevy_core_widgets/src/lib.rs | 3 + examples/ui/scrollbars.rs | 201 +++++++++++ .../release-notes/headless-widgets.md | 2 + 5 files changed, 528 insertions(+) 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 bde47050a98e9..f55dc98864cb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4530,6 +4530,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 0000000000000..33456de0d90dd --- /dev/null +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -0,0 +1,311 @@ +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::{Drag, DragEnd, DragStart, Pointer, Press}; +use bevy_ui::{ + ComputedNode, ComputedNodeTarget, Node, ScrollPosition, UiGlobalTransform, UiScale, Val, +}; + +/// Used to select the orientation of the scrollbar. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum Orientation { + /// 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. This component emits +/// [`ValueChange`] events when the scrollbar value changes. +/// +/// Scrollbars operate differently than the other core widgets in a number of respects. +/// +/// Unlike sliders, scrollbars don't have an [`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 appication 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)] +#[require(ScrollbarDragState)] +pub struct CoreScrollbar { + /// Entity being scrolled. + pub target: Entity, + /// Whether the scrollbar is vertical or horizontal. + pub orientation: Orientation, + /// Minimum size of the scrollbar thumb, in pixel units. + pub min_thumb_size: f32, +} + +/// Marker component to indicate that the entity is a scrollbar thumb. This should be a child +/// of the scrollbar entity. +#[derive(Component, Debug)] +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_size` - The minimum size of the scrollbar's thumb, in pixels. + pub fn new(target: Entity, orientation: Orientation, min_thumb_size: f32) -> Self { + Self { + target, + orientation, + min_thumb_size, + } + } +} + +/// Component used to manage the state of a scrollbar during dragging. +#[derive(Component, Default)] +pub struct ScrollbarDragState { + /// Whether the scrollbar is currently being dragged. + dragging: bool, + /// The value of the scrollbar when dragging started. + offset: 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); + match scrollbar.orientation { + Orientation::Horizontal => { + if node.size().x > 0. { + let click_pos = local_pos.x * content_size.x / node.size().x; + scroll_pos.offset_x = (scroll_pos.offset_x + + if click_pos > scroll_pos.offset_x { + visible_size.x + } else { + -visible_size.x + }) + .clamp(0., max_range.x); + } + } + Orientation::Vertical => { + if node.size().y > 0. { + let click_pos = local_pos.y * content_size.y / node.size().y; + scroll_pos.offset_y = (scroll_pos.offset_y + + if click_pos > scroll_pos.offset_y { + visible_size.y + } else { + -visible_size.y + }) + .clamp(0., max_range.y); + } + } + } + } +} + +fn scrollbar_on_drag_start( + mut ev: On>, + q_thumb: Query<&ChildOf, With>, + mut q_scrollbar: Query<(&CoreScrollbar, &mut ScrollbarDragState)>, + q_scroll_area: Query<&ScrollPosition>, +) { + if let Ok(ChildOf(thumb_parent)) = q_thumb.get(ev.target()) { + ev.propagate(false); + if let Ok((scrollbar, mut drag)) = q_scrollbar.get_mut(*thumb_parent) { + if let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) { + drag.dragging = true; + drag.offset = match scrollbar.orientation { + Orientation::Horizontal => scroll_area.offset_x, + Orientation::Vertical => scroll_area.offset_y, + }; + } + } + } +} + +fn scrollbar_on_drag( + mut ev: On>, + mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar, &mut ScrollbarDragState)>, + mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without>, +) { + if let Ok((node, scrollbar, drag)) = q_scrollbar.get_mut(ev.target()) { + 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; + let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor; + let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor; + match scrollbar.orientation { + Orientation::Horizontal => { + let range = (content_size.x - visible_size.x).max(0.); + let scrollbar_width = (node.size().x * node.inverse_scale_factor + - scrollbar.min_thumb_size) + .max(1.0); + scroll_pos.offset_x = if range > 0. { + (drag.offset + (distance.x * content_size.x) / scrollbar_width) + .clamp(0., range) + } else { + 0. + } + } + Orientation::Vertical => { + let range = (content_size.y - visible_size.y).max(0.); + let scrollbar_height = (node.size().y * node.inverse_scale_factor + - scrollbar.min_thumb_size) + .max(1.0); + scroll_pos.offset_y = if range > 0. { + (drag.offset + (distance.y * content_size.y) / scrollbar_height) + .clamp(0., range) + } else { + 0. + } + } + }; + } + } +} + +fn scrollbar_on_drag_end( + mut ev: On>, + mut q_scrollbar: Query<(&CoreScrollbar, &mut ScrollbarDragState)>, +) { + if let Ok((_scrollbar, mut drag)) = q_scrollbar.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; + + for child in children { + if let Ok(mut thumb) = q_thumb.get_mut(*child) { + match scrollbar.orientation { + Orientation::Horizontal => { + let thumb_size = if content_size.x > visible_size.x { + (track_length.x * visible_size.x / content_size.x) + .max(scrollbar.min_thumb_size) + .min(track_length.x) + } else { + track_length.x + }; + + let thumb_pos = if content_size.x > visible_size.x { + scroll_area.0.offset_x * (track_length.x - thumb_size) + / (content_size.x - visible_size.x) + } else { + 0. + }; + + thumb.top = Val::Px(0.); + thumb.bottom = Val::Px(0.); + thumb.left = Val::Px(thumb_pos); + thumb.width = Val::Px(thumb_size); + } + Orientation::Vertical => { + let thumb_size = if content_size.y > visible_size.y { + (track_length.y * visible_size.y / content_size.y) + .max(scrollbar.min_thumb_size) + .min(track_length.y) + } else { + track_length.y + }; + + let thumb_pos = if content_size.y > visible_size.y { + scroll_area.0.offset_y * (track_length.y - thumb_size) + / (content_size.y - visible_size.y) + } else { + 0. + }; + + 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) + .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 ef9f3db51c2e1..2649d632d2b4d 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,7 @@ 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::{CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, Orientation}; pub use core_slider::{ CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue, SliderRange, SliderStep, SliderValue, TrackClick, @@ -39,6 +41,7 @@ impl Plugin for CoreWidgetsPlugin { CoreButtonPlugin, CoreCheckboxPlugin, CoreRadioGroupPlugin, + CoreScrollbarPlugin, CoreSliderPlugin, )); } diff --git a/examples/ui/scrollbars.rs b/examples/ui/scrollbars.rs new file mode 100644 index 0000000000000..dc5aebbfbc1ef --- /dev/null +++ b/examples/ui/scrollbars.rs @@ -0,0 +1,201 @@ +//! Demonstrations of scrolling and scrollbars. + +use bevy::{ + core_widgets::{CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, Orientation}, + 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, + )) + .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: Orientation::Vertical, + target: scroll_area_id, + min_thumb_size: 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: Orientation::Horizontal, + target: scroll_area_id, + min_thumb_size: 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), + (With, Changed), + >, +) { + for (mut thumb_bg, Hovered(is_hovering)) in q_thumb.iter_mut() { + let color: Color = if *is_hovering { + // 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 e28c44ee9efc8..661ba774b1e38 100644 --- a/release-content/release-notes/headless-widgets.md +++ b/release-content/release-notes/headless-widgets.md @@ -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 From 0b01d2d75bd7caf433d61bba7d01dfcbb9b7733a Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 24 Jun 2025 09:40:55 -0700 Subject: [PATCH 02/13] Updated release note. --- release-content/release-notes/headless-widgets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-content/release-notes/headless-widgets.md b/release-content/release-notes/headless-widgets.md index 661ba774b1e38..bb0398b43caeb 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 From 7e139d2b51986c834b218a029e84443024837b27 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 24 Jun 2025 09:49:25 -0700 Subject: [PATCH 03/13] Fix typos and readme. --- crates/bevy_core_widgets/src/core_scrollbar.rs | 2 +- examples/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index 33456de0d90dd..0bd3c5e351b66 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -41,7 +41,7 @@ pub enum Orientation { /// 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 appication is free to position the scrollbars relative to the scrolling container however +/// 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)] diff --git a/examples/README.md b/examples/README.md index a8b9bfb3b406b..33f676d089450 100644 --- a/examples/README.md +++ b/examples/README.md @@ -562,6 +562,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 From 012de92e453f9968866dce8a05a93ba122a10493 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 24 Jun 2025 10:07:00 -0700 Subject: [PATCH 04/13] Fix doc comment errors. --- crates/bevy_core_widgets/src/core_scrollbar.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index 0bd3c5e351b66..db1c83d15c220 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -23,12 +23,11 @@ pub enum Orientation { Vertical, } -/// A headless scrollbar widget, which can be used to build custom scrollbars. This component emits -/// [`ValueChange`] events when the scrollbar value changes. +/// 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`] component, nor can they have +/// Unlike sliders, scrollbars don't have an `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. From cdac72289022fcfebc95f00d813b3cb83d7d05b9 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 24 Jun 2025 14:06:06 -0700 Subject: [PATCH 05/13] Update docs and comments from review feedback. --- .../bevy_core_widgets/src/core_scrollbar.rs | 58 ++++++++++--------- crates/bevy_core_widgets/src/lib.rs | 4 +- examples/ui/scrollbars.rs | 6 +- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index db1c83d15c220..bff4ce78f855b 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -13,9 +13,10 @@ use bevy_ui::{ ComputedNode, ComputedNodeTarget, Node, ScrollPosition, UiGlobalTransform, UiScale, Val, }; -/// Used to select the orientation of the scrollbar. +/// 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 Orientation { +pub enum ControlOrientation { /// Horizontal orientation (stretching from left to right) Horizontal, /// Vertical orientation (stretching from top to bottom) @@ -27,18 +28,19 @@ pub enum Orientation { /// /// Scrollbars operate differently than the other core widgets in a number of respects. /// -/// Unlike sliders, scrollbars don't have an `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. +/// 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. +/// 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. +/// 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 @@ -49,13 +51,13 @@ pub struct CoreScrollbar { /// Entity being scrolled. pub target: Entity, /// Whether the scrollbar is vertical or horizontal. - pub orientation: Orientation, + pub orientation: ControlOrientation, /// Minimum size of the scrollbar thumb, in pixel units. pub min_thumb_size: f32, } -/// Marker component to indicate that the entity is a scrollbar thumb. This should be a child -/// of the scrollbar entity. +/// 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)] pub struct CoreScrollbarThumb; @@ -67,7 +69,7 @@ impl CoreScrollbar { /// * `target` - The scrollable entity that this scrollbar will control. /// * `orientation` - The orientation of the scrollbar (horizontal or vertical). /// * `min_thumb_size` - The minimum size of the scrollbar's thumb, in pixels. - pub fn new(target: Entity, orientation: Orientation, min_thumb_size: f32) -> Self { + pub fn new(target: Entity, orientation: ControlOrientation, min_thumb_size: f32) -> Self { Self { target, orientation, @@ -82,7 +84,7 @@ pub struct ScrollbarDragState { /// Whether the scrollbar is currently being dragged. dragging: bool, /// The value of the scrollbar when dragging started. - offset: f32, + drag_origin: f32, } fn scrollbar_on_pointer_down( @@ -121,7 +123,7 @@ fn scrollbar_on_pointer_down( let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor; let max_range = (content_size - visible_size).max(Vec2::ZERO); match scrollbar.orientation { - Orientation::Horizontal => { + ControlOrientation::Horizontal => { if node.size().x > 0. { let click_pos = local_pos.x * content_size.x / node.size().x; scroll_pos.offset_x = (scroll_pos.offset_x @@ -133,7 +135,7 @@ fn scrollbar_on_pointer_down( .clamp(0., max_range.x); } } - Orientation::Vertical => { + ControlOrientation::Vertical => { if node.size().y > 0. { let click_pos = local_pos.y * content_size.y / node.size().y; scroll_pos.offset_y = (scroll_pos.offset_y @@ -160,9 +162,9 @@ fn scrollbar_on_drag_start( if let Ok((scrollbar, mut drag)) = q_scrollbar.get_mut(*thumb_parent) { if let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) { drag.dragging = true; - drag.offset = match scrollbar.orientation { - Orientation::Horizontal => scroll_area.offset_x, - Orientation::Vertical => scroll_area.offset_y, + drag.drag_origin = match scrollbar.orientation { + ControlOrientation::Horizontal => scroll_area.offset_x, + ControlOrientation::Vertical => scroll_area.offset_y, }; } } @@ -185,25 +187,25 @@ fn scrollbar_on_drag( let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor; let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor; match scrollbar.orientation { - Orientation::Horizontal => { + ControlOrientation::Horizontal => { let range = (content_size.x - visible_size.x).max(0.); let scrollbar_width = (node.size().x * node.inverse_scale_factor - scrollbar.min_thumb_size) .max(1.0); scroll_pos.offset_x = if range > 0. { - (drag.offset + (distance.x * content_size.x) / scrollbar_width) + (drag.drag_origin + (distance.x * content_size.x) / scrollbar_width) .clamp(0., range) } else { 0. } } - Orientation::Vertical => { + ControlOrientation::Vertical => { let range = (content_size.y - visible_size.y).max(0.); let scrollbar_height = (node.size().y * node.inverse_scale_factor - scrollbar.min_thumb_size) .max(1.0); scroll_pos.offset_y = if range > 0. { - (drag.offset + (distance.y * content_size.y) / scrollbar_height) + (drag.drag_origin + (distance.y * content_size.y) / scrollbar_height) .clamp(0., range) } else { 0. @@ -248,7 +250,7 @@ fn update_scrollbar_thumb( for child in children { if let Ok(mut thumb) = q_thumb.get_mut(*child) { match scrollbar.orientation { - Orientation::Horizontal => { + ControlOrientation::Horizontal => { let thumb_size = if content_size.x > visible_size.x { (track_length.x * visible_size.x / content_size.x) .max(scrollbar.min_thumb_size) @@ -269,7 +271,7 @@ fn update_scrollbar_thumb( thumb.left = Val::Px(thumb_pos); thumb.width = Val::Px(thumb_size); } - Orientation::Vertical => { + ControlOrientation::Vertical => { let thumb_size = if content_size.y > visible_size.y { (track_length.y * visible_size.y / content_size.y) .max(scrollbar.min_thumb_size) diff --git a/crates/bevy_core_widgets/src/lib.rs b/crates/bevy_core_widgets/src/lib.rs index 2649d632d2b4d..b7534397aac39 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -25,7 +25,9 @@ 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::{CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, Orientation}; +pub use core_scrollbar::{ + ControlOrientation, CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, +}; pub use core_slider::{ CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue, SliderRange, SliderStep, SliderValue, TrackClick, diff --git a/examples/ui/scrollbars.rs b/examples/ui/scrollbars.rs index dc5aebbfbc1ef..6e44f872b081a 100644 --- a/examples/ui/scrollbars.rs +++ b/examples/ui/scrollbars.rs @@ -1,7 +1,7 @@ //! Demonstrations of scrolling and scrollbars. use bevy::{ - core_widgets::{CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, Orientation}, + core_widgets::{ControlOrientation, CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb}, ecs::{relationship::RelatedSpawner, spawn::SpawnWith}, input_focus::{ tab_navigation::{TabGroup, TabNavigationPlugin}, @@ -113,7 +113,7 @@ fn scroll_area_demo() -> impl Bundle { ..default() }, CoreScrollbar { - orientation: Orientation::Vertical, + orientation: ControlOrientation::Vertical, target: scroll_area_id, min_thumb_size: 8.0, }, @@ -138,7 +138,7 @@ fn scroll_area_demo() -> impl Bundle { ..default() }, CoreScrollbar { - orientation: Orientation::Horizontal, + orientation: ControlOrientation::Horizontal, target: scroll_area_id, min_thumb_size: 8.0, }, From 94df55d6b3368fafa21fef01656e02aafc99c931 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 24 Jun 2025 16:58:07 -0700 Subject: [PATCH 06/13] Factor out common code between horizontal and vertical. --- .../bevy_core_widgets/src/core_scrollbar.rs | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index bff4ce78f855b..8855373d8f9f3 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -52,7 +52,10 @@ pub struct CoreScrollbar { pub target: Entity, /// Whether the scrollbar is vertical or horizontal. pub orientation: ControlOrientation, - /// Minimum size of the scrollbar thumb, in pixel units. + /// Minimum size of the scrollbar thumb, in pixel units. 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 to visible + /// is large. pub min_thumb_size: f32, } @@ -247,24 +250,41 @@ fn update_scrollbar_thumb( // 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 = if content_size.x > visible_size.x { - (track_length.x * visible_size.x / content_size.x) - .max(scrollbar.min_thumb_size) - .min(track_length.x) - } else { - track_length.x - }; - - let thumb_pos = if content_size.x > visible_size.x { - scroll_area.0.offset_x * (track_length.x - thumb_size) - / (content_size.x - visible_size.x) - } else { - 0. - }; + let (thumb_size, thumb_pos) = size_and_pos( + content_size.x, + visible_size.x, + track_length.x, + scrollbar.min_thumb_size, + scroll_area.0.offset_x, + ); thumb.top = Val::Px(0.); thumb.bottom = Val::Px(0.); @@ -272,20 +292,13 @@ fn update_scrollbar_thumb( thumb.width = Val::Px(thumb_size); } ControlOrientation::Vertical => { - let thumb_size = if content_size.y > visible_size.y { - (track_length.y * visible_size.y / content_size.y) - .max(scrollbar.min_thumb_size) - .min(track_length.y) - } else { - track_length.y - }; - - let thumb_pos = if content_size.y > visible_size.y { - scroll_area.0.offset_y * (track_length.y - thumb_size) - / (content_size.y - visible_size.y) - } else { - 0. - }; + let (thumb_size, thumb_pos) = size_and_pos( + content_size.y, + visible_size.y, + track_length.y, + scrollbar.min_thumb_size, + scroll_area.0.offset_y, + ); thumb.left = Val::Px(0.); thumb.right = Val::Px(0.); From c8aec4ac1af30e2af4150b75c08322091b457148 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 25 Jun 2025 08:06:31 -0700 Subject: [PATCH 07/13] Update crates/bevy_core_widgets/src/core_scrollbar.rs Co-authored-by: ickshonpe --- crates/bevy_core_widgets/src/core_scrollbar.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index 8855373d8f9f3..eed45dec0a3b9 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -178,6 +178,7 @@ fn scrollbar_on_drag( mut ev: On>, mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar, &mut ScrollbarDragState)>, mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without>, + ui_scale: Res, ) { if let Ok((node, scrollbar, drag)) = q_scrollbar.get_mut(ev.target()) { ev.propagate(false); @@ -186,7 +187,7 @@ fn scrollbar_on_drag( }; if drag.dragging { - let distance = ev.event().distance; + 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; match scrollbar.orientation { From a323db614adccace11f6eead8a6005106c904ba0 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 25 Jun 2025 08:06:41 -0700 Subject: [PATCH 08/13] Update examples/ui/scrollbars.rs Co-authored-by: ickshonpe --- examples/ui/scrollbars.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/ui/scrollbars.rs b/examples/ui/scrollbars.rs index 6e44f872b081a..b7919747c26ee 100644 --- a/examples/ui/scrollbars.rs +++ b/examples/ui/scrollbars.rs @@ -19,6 +19,7 @@ fn main() { InputDispatchPlugin, TabNavigationPlugin, )) + .insert_resource(UiScale(1.25)) .add_systems(Startup, setup_view_root) .add_systems(Update, update_scrollbar_thumb) .run(); From ee0dae300ac320a3f958243e39c6fedcefd24e61 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 25 Jun 2025 08:41:35 -0700 Subject: [PATCH 09/13] Bit more refactoring. --- .../bevy_core_widgets/src/core_scrollbar.rs | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index eed45dec0a3b9..91aa262407e37 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -125,29 +125,33 @@ fn scrollbar_on_pointer_down( 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; - scroll_pos.offset_x = (scroll_pos.offset_x - + if click_pos > scroll_pos.offset_x { - visible_size.x - } else { - -visible_size.x - }) - .clamp(0., max_range.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; - scroll_pos.offset_y = (scroll_pos.offset_y - + if click_pos > scroll_pos.offset_y { - visible_size.y - } else { - -visible_size.y - }) - .clamp(0., max_range.y); + adjust_scroll_pos( + &mut scroll_pos.offset_y, + click_pos, + visible_size.y, + max_range.y, + ); } } } @@ -190,30 +194,20 @@ fn scrollbar_on_drag( 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.); - let scrollbar_width = (node.size().x * node.inverse_scale_factor - - scrollbar.min_thumb_size) - .max(1.0); - scroll_pos.offset_x = if range > 0. { - (drag.drag_origin + (distance.x * content_size.x) / scrollbar_width) - .clamp(0., range) - } else { - 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.); - let scrollbar_height = (node.size().y * node.inverse_scale_factor - - scrollbar.min_thumb_size) - .max(1.0); - scroll_pos.offset_y = if range > 0. { - (drag.drag_origin + (distance.y * content_size.y) / scrollbar_height) - .clamp(0., range) - } else { - 0. - } + scroll_pos.offset_y = (drag.drag_origin + + (distance.y * content_size.y) / scrollbar_size.y) + .clamp(0., range); } }; } From 98a97ec6284d066dde073de077645e2378ca5c2f Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 25 Jun 2025 08:53:51 -0700 Subject: [PATCH 10/13] Make ScrollbarDragState public. --- crates/bevy_core_widgets/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_core_widgets/src/lib.rs b/crates/bevy_core_widgets/src/lib.rs index b7534397aac39..e8209f6c088c2 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -26,7 +26,7 @@ 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, CoreScrollbarPlugin, CoreScrollbarThumb, + ControlOrientation, CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb, ScrollbarDragState, }; pub use core_slider::{ CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue, From cbf48412c546662e677c90cef7590e51385a1538 Mon Sep 17 00:00:00 2001 From: Talin Date: Thu, 26 Jun 2025 08:44:03 -0700 Subject: [PATCH 11/13] Renamed thumb size --- crates/bevy_core_widgets/src/core_scrollbar.rs | 16 ++++++++-------- examples/ui/scrollbars.rs | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index 91aa262407e37..471bf02b9099c 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -52,11 +52,11 @@ pub struct CoreScrollbar { pub target: Entity, /// Whether the scrollbar is vertical or horizontal. pub orientation: ControlOrientation, - /// Minimum size of the scrollbar thumb, in pixel units. 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 to visible - /// is large. - pub min_thumb_size: f32, + /// 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 @@ -76,7 +76,7 @@ impl CoreScrollbar { Self { target, orientation, - min_thumb_size, + min_thumb_length: min_thumb_size, } } } @@ -277,7 +277,7 @@ fn update_scrollbar_thumb( content_size.x, visible_size.x, track_length.x, - scrollbar.min_thumb_size, + scrollbar.min_thumb_length, scroll_area.0.offset_x, ); @@ -291,7 +291,7 @@ fn update_scrollbar_thumb( content_size.y, visible_size.y, track_length.y, - scrollbar.min_thumb_size, + scrollbar.min_thumb_length, scroll_area.0.offset_y, ); diff --git a/examples/ui/scrollbars.rs b/examples/ui/scrollbars.rs index b7919747c26ee..1d00d72b1fecd 100644 --- a/examples/ui/scrollbars.rs +++ b/examples/ui/scrollbars.rs @@ -116,7 +116,7 @@ fn scroll_area_demo() -> impl Bundle { CoreScrollbar { orientation: ControlOrientation::Vertical, target: scroll_area_id, - min_thumb_size: 8.0, + min_thumb_length: 8.0, }, Children::spawn(Spawn(( Node { @@ -141,7 +141,7 @@ fn scroll_area_demo() -> impl Bundle { CoreScrollbar { orientation: ControlOrientation::Horizontal, target: scroll_area_id, - min_thumb_size: 8.0, + min_thumb_length: 8.0, }, Children::spawn(Spawn(( Node { From 436fee3a6dbf4f3b4827f8e33532f3f769753655 Mon Sep 17 00:00:00 2001 From: Talin Date: Thu, 26 Jun 2025 09:24:49 -0700 Subject: [PATCH 12/13] Moved drag state to scrollbar thumb. Example now highlights thumb while dragging. --- .../bevy_core_widgets/src/core_scrollbar.rs | 91 +++++++++++-------- crates/bevy_core_widgets/src/lib.rs | 3 +- examples/ui/scrollbars.rs | 16 +++- 3 files changed, 68 insertions(+), 42 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index 471bf02b9099c..65ae6560a1fcc 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -8,7 +8,7 @@ use bevy_ecs::{ system::{Query, Res}, }; use bevy_math::Vec2; -use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press}; +use bevy_picking::events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press}; use bevy_ui::{ ComputedNode, ComputedNodeTarget, Node, ScrollPosition, UiGlobalTransform, UiScale, Val, }; @@ -46,7 +46,6 @@ pub enum ControlOrientation { /// 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)] -#[require(ScrollbarDragState)] pub struct CoreScrollbar { /// Entity being scrolled. pub target: Entity, @@ -62,6 +61,7 @@ pub struct CoreScrollbar { /// 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 { @@ -81,11 +81,12 @@ impl CoreScrollbar { } } -/// Component used to manage the state of a scrollbar during dragging. +/// Component used to manage the state of a scrollbar during dragging. This component is +/// inserted on the thumb entity. #[derive(Component, Default)] -pub struct ScrollbarDragState { +pub struct CoreScrollbarDragState { /// Whether the scrollbar is currently being dragged. - dragging: bool, + pub dragging: bool, /// The value of the scrollbar when dragging started. drag_origin: f32, } @@ -160,13 +161,13 @@ fn scrollbar_on_pointer_down( fn scrollbar_on_drag_start( mut ev: On>, - q_thumb: Query<&ChildOf, With>, - mut q_scrollbar: Query<(&CoreScrollbar, &mut ScrollbarDragState)>, + mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With>, + q_scrollbar: Query<&CoreScrollbar>, q_scroll_area: Query<&ScrollPosition>, ) { - if let Ok(ChildOf(thumb_parent)) = q_thumb.get(ev.target()) { + if let Ok((ChildOf(thumb_parent), mut drag)) = q_thumb.get_mut(ev.target()) { ev.propagate(false); - if let Ok((scrollbar, mut drag)) = q_scrollbar.get_mut(*thumb_parent) { + 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 { @@ -180,45 +181,62 @@ fn scrollbar_on_drag_start( fn scrollbar_on_drag( mut ev: On>, - mut q_scrollbar: Query<(&ComputedNode, &CoreScrollbar, &mut ScrollbarDragState)>, + 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((node, scrollbar, drag)) = q_scrollbar.get_mut(ev.target()) { - ev.propagate(false); - let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else { - return; - }; + 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); + 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); - } - }; + 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_scrollbar: Query<(&CoreScrollbar, &mut ScrollbarDragState)>, + 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((_scrollbar, mut drag)) = q_scrollbar.get_mut(ev.target()) { + if let Ok(mut drag) = q_thumb.get_mut(ev.target()) { ev.propagate(false); if drag.dragging { drag.dragging = false; @@ -314,6 +332,7 @@ impl Plugin for CoreScrollbarPlugin { 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 e8209f6c088c2..a0ddfa5eb8848 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -26,7 +26,8 @@ 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, CoreScrollbarPlugin, CoreScrollbarThumb, ScrollbarDragState, + ControlOrientation, CoreScrollbar, CoreScrollbarDragState, CoreScrollbarPlugin, + CoreScrollbarThumb, }; pub use core_slider::{ CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue, diff --git a/examples/ui/scrollbars.rs b/examples/ui/scrollbars.rs index 1d00d72b1fecd..2726df12fb621 100644 --- a/examples/ui/scrollbars.rs +++ b/examples/ui/scrollbars.rs @@ -1,7 +1,10 @@ //! Demonstrations of scrolling and scrollbars. use bevy::{ - core_widgets::{ControlOrientation, CoreScrollbar, CoreScrollbarPlugin, CoreScrollbarThumb}, + core_widgets::{ + ControlOrientation, CoreScrollbar, CoreScrollbarDragState, CoreScrollbarPlugin, + CoreScrollbarThumb, + }, ecs::{relationship::RelatedSpawner, spawn::SpawnWith}, input_focus::{ tab_navigation::{TabGroup, TabNavigationPlugin}, @@ -172,12 +175,15 @@ fn text_row(caption: &str) -> impl Bundle { // Update the color of the scrollbar thumb. fn update_scrollbar_thumb( mut q_thumb: Query< - (&mut BackgroundColor, &Hovered), - (With, Changed), + (&mut BackgroundColor, &Hovered, &CoreScrollbarDragState), + ( + With, + Or<(Changed, Changed)>, + ), >, ) { - for (mut thumb_bg, Hovered(is_hovering)) in q_thumb.iter_mut() { - let color: Color = if *is_hovering { + 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 { From d042ade6ce69a394f5b52340610f17d76cc3829c Mon Sep 17 00:00:00 2001 From: Talin Date: Thu, 26 Jun 2025 10:35:43 -0700 Subject: [PATCH 13/13] Missed a renaming. --- crates/bevy_core_widgets/src/core_scrollbar.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_scrollbar.rs b/crates/bevy_core_widgets/src/core_scrollbar.rs index 65ae6560a1fcc..2d0fd49fb61be 100644 --- a/crates/bevy_core_widgets/src/core_scrollbar.rs +++ b/crates/bevy_core_widgets/src/core_scrollbar.rs @@ -71,12 +71,12 @@ impl CoreScrollbar { /// /// * `target` - The scrollable entity that this scrollbar will control. /// * `orientation` - The orientation of the scrollbar (horizontal or vertical). - /// * `min_thumb_size` - The minimum size of the scrollbar's thumb, in pixels. - pub fn new(target: Entity, orientation: ControlOrientation, min_thumb_size: f32) -> Self { + /// * `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: min_thumb_size, + min_thumb_length, } } }