Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: "CoreScrollbarThumb is now `ScrollbarThumb` and scroll bar thumb layout is updated separately"
pull_requests: [23612]
---

`CoreScrollbarThumb` has been renamed to `ScrollbarThumb`.

`ScrollbarThumb` nodes are now laid out after `ui_layout_system` by `update_scrollbar_thumb`. `ScrollbarThumb` entities do
not have a `Node` component. The only layout options are for borders, which can be set using `ScrollbarThumb`'s new
`border` and `border_radius` fields.
4 changes: 2 additions & 2 deletions crates/bevy_ui_render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ pub fn extract_uinode_borders(
uinode_query: Extract<
Query<(
Entity,
&Node,
Option<&Node>,
&ComputedNode,
&UiGlobalTransform,
&InheritedVisibility,
Expand All @@ -593,7 +593,7 @@ pub fn extract_uinode_borders(
) in &uinode_query
{
// Skip invisible borders and removed nodes
if !inherited_visibility.get() || node.display == Display::None {
if !inherited_visibility.get() || node.is_some_and(|node| node.display == Display::None) {
continue;
}

Expand Down
236 changes: 168 additions & 68 deletions crates/bevy_ui_widgets/src/scrollbar.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
use bevy_app::{App, Plugin, PostUpdate};
use bevy_camera::visibility::Visibility;
use bevy_ecs::{
change_detection::DetectChangesMut,
component::Component,
entity::Entity,
hierarchy::{ChildOf, Children},
observer::On,
query::{With, Without},
reflect::ReflectComponent,
schedule::IntoScheduleConfigs,
system::{Query, Res},
};
use bevy_math::Vec2;
use bevy_math::{Affine2, Vec2};
use bevy_picking::events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_ui::{
ComputedNode, ComputedUiRenderTargetInfo, Node, ScrollPosition, UiGlobalTransform, UiScale, Val,
prelude::BorderRect, ui_layout_system, BackgroundColor, BorderColor, BorderRadius,
ComputedNode, ComputedUiRenderTargetInfo, ComputedUiTargetCamera, FocusPolicy, ScrollPosition,
UiGlobalTransform, UiRect, UiScale, UiSystems, UiTransform, Val, ZIndex,
};

/// Used to select the orientation of a scrollbar, slider, or other oriented control.
Expand Down Expand Up @@ -41,7 +46,7 @@ pub enum ControlOrientation {
/// 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
/// which is marked with the [`ScrollbarThumb`] 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.
///
Expand All @@ -62,13 +67,35 @@ pub struct Scrollbar {
pub min_thumb_length: f32,
}

/// Marker component to indicate that the entity is a scrollbar thumb (the moving, draggable part of
/// This component indicates 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)]
///
/// A `ScrollbarThumb` UI node does not have a `Node` component. It only has `Border` and `BorderRadius` styling properties.
/// Its layout is handled after `ui_layout_system` in `update_scroll_thumb` so that its size and position can be set relative to the scrolling area's
/// size and scroll position.
#[derive(Component, Debug, Default)]
#[require(
Comment thread
ickshonpe marked this conversation as resolved.
CoreScrollbarDragState,
ComputedNode,
ComputedUiTargetCamera,
ComputedUiRenderTargetInfo,
UiTransform,
BackgroundColor,
BorderColor,
FocusPolicy::Block,
Visibility,
ZIndex
)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct CoreScrollbarThumb;
pub struct ScrollbarThumb {
/// Border radius of the scrollbar thumb, used to update [`ComputedNode::border_radius`] in [`UiSystems::Layout`].
pub border_radius: BorderRadius,
/// Thickness of the thumb node's border.
///
/// If the border is too large to fit inside the thumb node, the width of the border will be scaled to fit.
pub border: UiRect,
}

impl Scrollbar {
/// Construct a new scrollbar.
Expand Down Expand Up @@ -100,7 +127,7 @@ pub struct CoreScrollbarDragState {

fn scrollbar_on_pointer_down(
mut ev: On<Pointer<Press>>,
q_thumb: Query<&ChildOf, With<CoreScrollbarThumb>>,
q_thumb: Query<&ChildOf, With<ScrollbarThumb>>,
mut q_scrollbar: Query<(
&Scrollbar,
&ComputedNode,
Expand Down Expand Up @@ -159,7 +186,7 @@ fn scrollbar_on_pointer_down(

fn scrollbar_on_drag_start(
mut ev: On<Pointer<DragStart>>,
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<ScrollbarThumb>>,
q_scrollbar: Query<&Scrollbar>,
q_scroll_area: Query<&ScrollPosition>,
) {
Expand All @@ -179,7 +206,7 @@ fn scrollbar_on_drag_start(

fn scrollbar_on_drag(
mut ev: On<Pointer<Drag>>,
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<ScrollbarThumb>>,
mut q_scrollbar: Query<(&ComputedNode, &Scrollbar)>,
mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<Scrollbar>>,
ui_scale: Res<UiScale>,
Expand Down Expand Up @@ -221,7 +248,7 @@ fn scrollbar_on_drag(

fn scrollbar_on_drag_end(
mut ev: On<Pointer<DragEnd>>,
mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
mut q_thumb: Query<&mut CoreScrollbarDragState, With<ScrollbarThumb>>,
) {
if let Ok(mut drag) = q_thumb.get_mut(ev.entity) {
ev.propagate(false);
Expand All @@ -233,7 +260,7 @@ fn scrollbar_on_drag_end(

fn scrollbar_on_drag_cancel(
mut ev: On<Pointer<Cancel>>,
mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
mut q_thumb: Query<&mut CoreScrollbarDragState, With<ScrollbarThumb>>,
) {
if let Ok(mut drag) = q_thumb.get_mut(ev.entity) {
ev.propagate(false);
Expand All @@ -244,11 +271,23 @@ fn scrollbar_on_drag_cancel(
}

fn update_scrollbar_thumb(
q_scroll_area: Query<(&ScrollPosition, &ComputedNode)>,
q_scrollbar: Query<(&Scrollbar, &ComputedNode, &Children)>,
mut q_thumb: Query<&mut Node, With<CoreScrollbarThumb>>,
q_scroll_area: Query<(&ScrollPosition, &ComputedNode), Without<ScrollbarThumb>>,
q_scrollbar: Query<
(&Scrollbar, &ComputedNode, &UiGlobalTransform, &Children),
Without<ScrollbarThumb>,
>,
mut q_thumb: Query<
(
&ScrollbarThumb,
&UiTransform,
&ComputedUiRenderTargetInfo,
&mut ComputedNode,
&mut UiGlobalTransform,
),
With<ScrollbarThumb>,
>,
) {
for (scrollbar, scrollbar_node, children) in q_scrollbar.iter() {
for (scrollbar, scrollbar_node, scrollbar_transform, children) in q_scrollbar.iter() {
let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) else {
continue;
};
Expand Down Expand Up @@ -297,57 +336,113 @@ fn update_scrollbar_thumb(
}

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.x,
);

let top = Val::Px(0.);
let bottom = Val::Px(0.);
let left = Val::Px(thumb_pos);
let width = Val::Px(thumb_size);
if top != thumb.top
|| bottom != thumb.bottom
|| left != thumb.left
|| width != thumb.width
{
thumb.top = top;
thumb.bottom = bottom;
thumb.left = left;
thumb.width = width;
}
}
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.y,
);

let left = Val::Px(0.);
let right = Val::Px(0.);
let top = Val::Px(thumb_pos);
let height = Val::Px(thumb_size);
if thumb.left != left
|| thumb.right != right
|| thumb.top != top
|| thumb.height != height
{
thumb.left = left;
thumb.right = right;
thumb.top = top;
thumb.height = height;
}
}
};
let Ok((
thumb,
thumb_transform,
target_info,
mut thumb_node,
mut thumb_global_transform,
)) = q_thumb.get_mut(*child)
else {
continue;
};

let (thumb_logical_size, thumb_center) = 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.x,
);
(
Vec2::new(thumb_size, track_length.y),
Vec2::new(thumb_pos + 0.5 * (thumb_size - track_length.x), 0.),
Comment thread
ickshonpe marked this conversation as resolved.
)
}
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.y,
);
(
Vec2::new(track_length.x, thumb_size),
Vec2::new(0., thumb_pos + 0.5 * (thumb_size - track_length.y)),
)
}
};

let inverse_scale_factor = target_info.scale_factor().recip();
let thumb_physical_size = thumb_logical_size * target_info.scale_factor();

if thumb_node.size != thumb_physical_size
|| thumb_node.unrounded_size != thumb_physical_size
|| thumb_node.inverse_scale_factor != inverse_scale_factor
{
thumb_node.size = thumb_physical_size;
thumb_node.unrounded_size = thumb_physical_size;
thumb_node.inverse_scale_factor = inverse_scale_factor;
}

let border_radius = thumb.border_radius.resolve(
target_info.scale_factor(),
thumb_physical_size,
target_info.physical_size().as_vec2(),
);
if thumb_node.border_radius != border_radius {
thumb_node.border_radius = border_radius;
}

let resolve_border_val = |val: Val| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually need to be two diff lambdas for thumb_physical_size.x and thumb_physical_size.y?

@ickshonpe ickshonpe Apr 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's iffy, but generally with percentage values both axes are resolved based on the horizontal width.

val.resolve(
target_info.scale_factor(),
thumb_physical_size.x,
target_info.physical_size().as_vec2(),
)
.unwrap_or(0.)
};

let mut resolved_border = BorderRect {
min_inset: Vec2::new(
resolve_border_val(thumb.border.left),
resolve_border_val(thumb.border.top),
),
max_inset: Vec2::new(
resolve_border_val(thumb.border.right),
resolve_border_val(thumb.border.bottom),
),
};

if thumb_node.size.x < resolved_border.min_inset.x + resolved_border.max_inset.x {
let r =
thumb_node.size.x / (resolved_border.min_inset.x + resolved_border.max_inset.x);
resolved_border.min_inset.x *= r;
resolved_border.max_inset.x *= r;
}

if thumb_node.size.y < resolved_border.min_inset.y + resolved_border.max_inset.y {
let r =
thumb_node.size.y / (resolved_border.min_inset.y + resolved_border.max_inset.y);
resolved_border.min_inset.y *= r;
resolved_border.max_inset.y *= r;
}

thumb_node.bypass_change_detection().border = resolved_border;

let new_transform = scrollbar_transform.affine()
* thumb_transform.compute_affine(
target_info.scale_factor(),
thumb_physical_size,
target_info.physical_size().as_vec2(),
)
* Affine2::from_translation(thumb_center * target_info.scale_factor());

if thumb_global_transform.affine() != new_transform {
*thumb_global_transform = new_transform.into();
}
}
}
Expand All @@ -363,6 +458,11 @@ impl Plugin for ScrollbarPlugin {
.add_observer(scrollbar_on_drag_end)
.add_observer(scrollbar_on_drag_cancel)
.add_observer(scrollbar_on_drag)
.add_systems(PostUpdate, update_scrollbar_thumb);
.add_systems(
PostUpdate,
update_scrollbar_thumb
.in_set(UiSystems::Layout)
.after(ui_layout_system),
);
}
}
Loading