Path: blob/main/crates/bevy_ui_widgets/src/scrollbar.rs
6849 views
use bevy_app::{App, Plugin, PostUpdate};1use bevy_ecs::{2component::Component,3entity::Entity,4hierarchy::{ChildOf, Children},5observer::On,6query::{With, Without},7reflect::ReflectComponent,8system::{Query, Res},9};10use bevy_math::Vec2;11use bevy_picking::events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press};12use bevy_reflect::{prelude::ReflectDefault, Reflect};13use bevy_ui::{14ComputedNode, ComputedUiRenderTargetInfo, Node, ScrollPosition, UiGlobalTransform, UiScale, Val,15};1617/// Used to select the orientation of a scrollbar, slider, or other oriented control.18// TODO: Move this to a more central place.19#[derive(Debug, Default, Clone, Copy, PartialEq, Reflect)]20#[reflect(PartialEq, Clone, Default)]21pub enum ControlOrientation {22/// Horizontal orientation (stretching from left to right)23Horizontal,24/// Vertical orientation (stretching from top to bottom)25#[default]26Vertical,27}2829/// A headless scrollbar widget, which can be used to build custom scrollbars.30///31/// Scrollbars operate differently than the other UI widgets in a number of respects.32///33/// Unlike sliders, scrollbars don't have an [`AccessibilityNode`](bevy_a11y::AccessibilityNode)34/// component, nor can they have keyboard focus. This is because scrollbars are usually used in35/// conjunction with a scrollable container, which is itself accessible and focusable. This also36/// means that scrollbars don't accept keyboard events, which is also the responsibility of the37/// scrollable container.38///39/// Scrollbars don't emit notification events; instead they modify the scroll position of the target40/// entity directly.41///42/// A scrollbar can have any number of child entities, but one entity must be the scrollbar thumb,43/// which is marked with the [`CoreScrollbarThumb`] component. Other children are ignored. The core44/// scrollbar will directly update the position and size of this entity; the application is free to45/// set any other style properties as desired.46///47/// The application is free to position the scrollbars relative to the scrolling container however48/// it wants: it can overlay them on top of the scrolling content, or use a grid layout to displace49/// the content to make room for the scrollbars.50#[derive(Component, Debug, Reflect)]51#[reflect(Component)]52pub struct Scrollbar {53/// Entity being scrolled.54pub target: Entity,55/// Whether the scrollbar is vertical or horizontal.56pub orientation: ControlOrientation,57/// Minimum length of the scrollbar thumb, in pixel units, in the direction parallel to the main58/// scrollbar axis. The scrollbar will resize the thumb entity based on the proportion of59/// visible size to content size, but no smaller than this. This prevents the thumb from60/// disappearing in cases where the ratio of content size to visible size is large.61pub min_thumb_length: f32,62}6364/// Marker component to indicate that the entity is a scrollbar thumb (the moving, draggable part of65/// the scrollbar). This should be a child of the scrollbar entity.66#[derive(Component, Debug)]67#[require(CoreScrollbarDragState)]68#[derive(Reflect)]69#[reflect(Component)]70pub struct CoreScrollbarThumb;7172impl Scrollbar {73/// Construct a new scrollbar.74///75/// # Arguments76///77/// * `target` - The scrollable entity that this scrollbar will control.78/// * `orientation` - The orientation of the scrollbar (horizontal or vertical).79/// * `min_thumb_length` - The minimum size of the scrollbar's thumb, in pixels.80pub fn new(target: Entity, orientation: ControlOrientation, min_thumb_length: f32) -> Self {81Self {82target,83orientation,84min_thumb_length,85}86}87}8889/// Component used to manage the state of a scrollbar during dragging. This component is90/// inserted on the thumb entity.91#[derive(Component, Default, Reflect)]92#[reflect(Component, Default)]93pub struct CoreScrollbarDragState {94/// Whether the scrollbar is currently being dragged.95pub dragging: bool,96/// The value of the scrollbar when dragging started.97drag_origin: f32,98}99100fn scrollbar_on_pointer_down(101mut ev: On<Pointer<Press>>,102q_thumb: Query<&ChildOf, With<CoreScrollbarThumb>>,103mut q_scrollbar: Query<(104&Scrollbar,105&ComputedNode,106&ComputedUiRenderTargetInfo,107&UiGlobalTransform,108)>,109mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<Scrollbar>>,110ui_scale: Res<UiScale>,111) {112if q_thumb.contains(ev.entity) {113// If they click on the thumb, do nothing. This will be handled by the drag event.114ev.propagate(false);115} else if let Ok((scrollbar, node, node_target, transform)) = q_scrollbar.get_mut(ev.entity) {116// If they click on the scrollbar track, page up or down.117ev.propagate(false);118119// Convert to widget-local coordinates.120let local_pos = transform.try_inverse().unwrap().transform_point2(121ev.event().pointer_location.position * node_target.scale_factor() / ui_scale.0,122) + node.size() * 0.5;123124// Bail if we don't find the target entity.125let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else {126return;127};128129// Convert the click coordinates into a scroll position. If it's greater than the130// current scroll position, scroll forward by one step (visible size) otherwise scroll131// back.132let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor;133let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor;134let max_range = (content_size - visible_size).max(Vec2::ZERO);135136fn adjust_scroll_pos(scroll_pos: &mut f32, click_pos: f32, step: f32, range: f32) {137*scroll_pos =138(*scroll_pos + if click_pos > *scroll_pos { step } else { -step }).clamp(0., range);139}140141match scrollbar.orientation {142ControlOrientation::Horizontal => {143if node.size().x > 0. {144let click_pos = local_pos.x * content_size.x / node.size().x;145adjust_scroll_pos(&mut scroll_pos.x, click_pos, visible_size.x, max_range.x);146}147}148ControlOrientation::Vertical => {149if node.size().y > 0. {150let click_pos = local_pos.y * content_size.y / node.size().y;151adjust_scroll_pos(&mut scroll_pos.y, click_pos, visible_size.y, max_range.y);152}153}154}155}156}157158fn scrollbar_on_drag_start(159mut ev: On<Pointer<DragStart>>,160mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,161q_scrollbar: Query<&Scrollbar>,162q_scroll_area: Query<&ScrollPosition>,163) {164if let Ok((ChildOf(thumb_parent), mut drag)) = q_thumb.get_mut(ev.entity) {165ev.propagate(false);166if let Ok(scrollbar) = q_scrollbar.get(*thumb_parent)167&& let Ok(scroll_area) = q_scroll_area.get(scrollbar.target)168{169drag.dragging = true;170drag.drag_origin = match scrollbar.orientation {171ControlOrientation::Horizontal => scroll_area.x,172ControlOrientation::Vertical => scroll_area.y,173};174}175}176}177178fn scrollbar_on_drag(179mut ev: On<Pointer<Drag>>,180mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,181mut q_scrollbar: Query<(&ComputedNode, &Scrollbar)>,182mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<Scrollbar>>,183ui_scale: Res<UiScale>,184) {185if let Ok((ChildOf(thumb_parent), drag)) = q_thumb.get_mut(ev.entity)186&& let Ok((node, scrollbar)) = q_scrollbar.get_mut(*thumb_parent)187{188ev.propagate(false);189let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else {190return;191};192193if drag.dragging {194let distance = ev.event().distance / ui_scale.0;195let visible_size = scroll_content.size() * scroll_content.inverse_scale_factor;196let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor;197let scrollbar_size = (node.size() * node.inverse_scale_factor).max(Vec2::ONE);198199match scrollbar.orientation {200ControlOrientation::Horizontal => {201let range = (content_size.x - visible_size.x).max(0.);202scroll_pos.x = (drag.drag_origin203+ (distance.x * content_size.x) / scrollbar_size.x)204.clamp(0., range);205}206ControlOrientation::Vertical => {207let range = (content_size.y - visible_size.y).max(0.);208scroll_pos.y = (drag.drag_origin209+ (distance.y * content_size.y) / scrollbar_size.y)210.clamp(0., range);211}212};213}214}215}216217fn scrollbar_on_drag_end(218mut ev: On<Pointer<DragEnd>>,219mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,220) {221if let Ok(mut drag) = q_thumb.get_mut(ev.entity) {222ev.propagate(false);223if drag.dragging {224drag.dragging = false;225}226}227}228229fn scrollbar_on_drag_cancel(230mut ev: On<Pointer<Cancel>>,231mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,232) {233if let Ok(mut drag) = q_thumb.get_mut(ev.entity) {234ev.propagate(false);235if drag.dragging {236drag.dragging = false;237}238}239}240241fn update_scrollbar_thumb(242q_scroll_area: Query<(&ScrollPosition, &ComputedNode)>,243q_scrollbar: Query<(&Scrollbar, &ComputedNode, &Children)>,244mut q_thumb: Query<&mut Node, With<CoreScrollbarThumb>>,245) {246for (scrollbar, scrollbar_node, children) in q_scrollbar.iter() {247let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) else {248continue;249};250251// Size of the visible scrolling area.252let visible_size = scroll_area.1.size() * scroll_area.1.inverse_scale_factor;253254// Size of the scrolling content.255let content_size = scroll_area.1.content_size() * scroll_area.1.inverse_scale_factor;256257// Length of the scrollbar track.258let track_length = scrollbar_node.size() * scrollbar_node.inverse_scale_factor;259260fn size_and_pos(261content_size: f32,262visible_size: f32,263track_length: f32,264min_size: f32,265offset: f32,266) -> (f32, f32) {267let thumb_size = if content_size > visible_size {268(track_length * visible_size / content_size)269.max(min_size)270.min(track_length)271} else {272track_length273};274275let thumb_pos = if content_size > visible_size {276offset * (track_length - thumb_size) / (content_size - visible_size)277} else {2780.279};280281(thumb_size, thumb_pos)282}283284for child in children {285if let Ok(mut thumb) = q_thumb.get_mut(*child) {286match scrollbar.orientation {287ControlOrientation::Horizontal => {288let (thumb_size, thumb_pos) = size_and_pos(289content_size.x,290visible_size.x,291track_length.x,292scrollbar.min_thumb_length,293scroll_area.0.x,294);295296thumb.top = Val::Px(0.);297thumb.bottom = Val::Px(0.);298thumb.left = Val::Px(thumb_pos);299thumb.width = Val::Px(thumb_size);300}301ControlOrientation::Vertical => {302let (thumb_size, thumb_pos) = size_and_pos(303content_size.y,304visible_size.y,305track_length.y,306scrollbar.min_thumb_length,307scroll_area.0.y,308);309310thumb.left = Val::Px(0.);311thumb.right = Val::Px(0.);312thumb.top = Val::Px(thumb_pos);313thumb.height = Val::Px(thumb_size);314}315};316}317}318}319}320321/// Plugin that adds the observers for the [`Scrollbar`] widget.322pub struct ScrollbarPlugin;323324impl Plugin for ScrollbarPlugin {325fn build(&self, app: &mut App) {326app.add_observer(scrollbar_on_pointer_down)327.add_observer(scrollbar_on_drag_start)328.add_observer(scrollbar_on_drag_end)329.add_observer(scrollbar_on_drag_cancel)330.add_observer(scrollbar_on_drag)331.add_systems(PostUpdate, update_scrollbar_thumb);332}333}334335336