bevy_ui_widgets/
scrollbar.rs

1use bevy_app::{App, Plugin, PostUpdate};
2use bevy_ecs::{
3    component::Component,
4    entity::Entity,
5    hierarchy::{ChildOf, Children},
6    observer::On,
7    query::{With, Without},
8    reflect::ReflectComponent,
9    system::{Query, Res},
10};
11use bevy_math::Vec2;
12use bevy_picking::events::{Cancel, Drag, DragEnd, DragStart, Pointer, Press};
13use bevy_reflect::{prelude::ReflectDefault, Reflect};
14use bevy_ui::{
15    ComputedNode, ComputedUiRenderTargetInfo, Node, ScrollPosition, UiGlobalTransform, UiScale, Val,
16};
17
18/// Used to select the orientation of a scrollbar, slider, or other oriented control.
19// TODO: Move this to a more central place.
20#[derive(Debug, Default, Clone, Copy, PartialEq, Reflect)]
21#[reflect(PartialEq, Clone, Default)]
22pub enum ControlOrientation {
23    /// Horizontal orientation (stretching from left to right)
24    Horizontal,
25    /// Vertical orientation (stretching from top to bottom)
26    #[default]
27    Vertical,
28}
29
30/// A headless scrollbar widget, which can be used to build custom scrollbars.
31///
32/// Scrollbars operate differently than the other UI widgets in a number of respects.
33///
34/// Unlike sliders, scrollbars don't have an [`AccessibilityNode`](bevy_a11y::AccessibilityNode)
35/// component, nor can they have keyboard focus. This is because scrollbars are usually used in
36/// conjunction with a scrollable container, which is itself accessible and focusable. This also
37/// means that scrollbars don't accept keyboard events, which is also the responsibility of the
38/// scrollable container.
39///
40/// Scrollbars don't emit notification events; instead they modify the scroll position of the target
41/// entity directly.
42///
43/// A scrollbar can have any number of child entities, but one entity must be the scrollbar thumb,
44/// which is marked with the [`CoreScrollbarThumb`] component. Other children are ignored. The core
45/// scrollbar will directly update the position and size of this entity; the application is free to
46/// set any other style properties as desired.
47///
48/// The application is free to position the scrollbars relative to the scrolling container however
49/// it wants: it can overlay them on top of the scrolling content, or use a grid layout to displace
50/// the content to make room for the scrollbars.
51#[derive(Component, Debug, Reflect)]
52#[reflect(Component)]
53pub struct Scrollbar {
54    /// Entity being scrolled.
55    pub target: Entity,
56    /// Whether the scrollbar is vertical or horizontal.
57    pub orientation: ControlOrientation,
58    /// Minimum length of the scrollbar thumb, in pixel units, in the direction parallel to the main
59    /// scrollbar axis. The scrollbar will resize the thumb entity based on the proportion of
60    /// visible size to content size, but no smaller than this. This prevents the thumb from
61    /// disappearing in cases where the ratio of content size to visible size is large.
62    pub min_thumb_length: f32,
63}
64
65/// Marker component to indicate that the entity is a scrollbar thumb (the moving, draggable part of
66/// the scrollbar). This should be a child of the scrollbar entity.
67#[derive(Component, Debug)]
68#[require(CoreScrollbarDragState)]
69#[derive(Reflect)]
70#[reflect(Component)]
71pub struct CoreScrollbarThumb;
72
73impl Scrollbar {
74    /// Construct a new scrollbar.
75    ///
76    /// # Arguments
77    ///
78    /// * `target` - The scrollable entity that this scrollbar will control.
79    /// * `orientation` - The orientation of the scrollbar (horizontal or vertical).
80    /// * `min_thumb_length` - The minimum size of the scrollbar's thumb, in pixels.
81    pub fn new(target: Entity, orientation: ControlOrientation, min_thumb_length: f32) -> Self {
82        Self {
83            target,
84            orientation,
85            min_thumb_length,
86        }
87    }
88}
89
90/// Component used to manage the state of a scrollbar during dragging. This component is
91/// inserted on the thumb entity.
92#[derive(Component, Default, Reflect)]
93#[reflect(Component, Default)]
94pub struct CoreScrollbarDragState {
95    /// Whether the scrollbar is currently being dragged.
96    pub dragging: bool,
97    /// The value of the scrollbar when dragging started.
98    drag_origin: f32,
99}
100
101fn scrollbar_on_pointer_down(
102    mut ev: On<Pointer<Press>>,
103    q_thumb: Query<&ChildOf, With<CoreScrollbarThumb>>,
104    mut q_scrollbar: Query<(
105        &Scrollbar,
106        &ComputedNode,
107        &ComputedUiRenderTargetInfo,
108        &UiGlobalTransform,
109    )>,
110    mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<Scrollbar>>,
111    ui_scale: Res<UiScale>,
112) {
113    if q_thumb.contains(ev.entity) {
114        // If they click on the thumb, do nothing. This will be handled by the drag event.
115        ev.propagate(false);
116    } else if let Ok((scrollbar, node, node_target, transform)) = q_scrollbar.get_mut(ev.entity) {
117        // If they click on the scrollbar track, page up or down.
118        ev.propagate(false);
119
120        // Convert to widget-local coordinates.
121        let local_pos = transform.try_inverse().unwrap().transform_point2(
122            ev.event().pointer_location.position * node_target.scale_factor() / ui_scale.0,
123        ) + node.size() * 0.5;
124
125        // Bail if we don't find the target entity.
126        let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else {
127            return;
128        };
129
130        // Convert the click coordinates into a scroll position. If it's greater than the
131        // current scroll position, scroll forward by one step (visible size) otherwise scroll
132        // back.
133        let visible_size = (scroll_content.size() - scroll_content.scrollbar_size)
134            * scroll_content.inverse_scale_factor;
135        let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor;
136        let max_range = (content_size - visible_size).max(Vec2::ZERO);
137
138        fn adjust_scroll_pos(scroll_pos: &mut f32, click_pos: f32, step: f32, range: f32) {
139            *scroll_pos =
140                (*scroll_pos + if click_pos > *scroll_pos { step } else { -step }).clamp(0., range);
141        }
142
143        match scrollbar.orientation {
144            ControlOrientation::Horizontal => {
145                if node.size().x > 0. {
146                    let click_pos = local_pos.x * content_size.x / node.size().x;
147                    adjust_scroll_pos(&mut scroll_pos.x, click_pos, visible_size.x, max_range.x);
148                }
149            }
150            ControlOrientation::Vertical => {
151                if node.size().y > 0. {
152                    let click_pos = local_pos.y * content_size.y / node.size().y;
153                    adjust_scroll_pos(&mut scroll_pos.y, click_pos, visible_size.y, max_range.y);
154                }
155            }
156        }
157    }
158}
159
160fn scrollbar_on_drag_start(
161    mut ev: On<Pointer<DragStart>>,
162    mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
163    q_scrollbar: Query<&Scrollbar>,
164    q_scroll_area: Query<&ScrollPosition>,
165) {
166    if let Ok((ChildOf(thumb_parent), mut drag)) = q_thumb.get_mut(ev.entity) {
167        ev.propagate(false);
168        if let Ok(scrollbar) = q_scrollbar.get(*thumb_parent)
169            && let Ok(scroll_area) = q_scroll_area.get(scrollbar.target)
170        {
171            drag.dragging = true;
172            drag.drag_origin = match scrollbar.orientation {
173                ControlOrientation::Horizontal => scroll_area.x,
174                ControlOrientation::Vertical => scroll_area.y,
175            };
176        }
177    }
178}
179
180fn scrollbar_on_drag(
181    mut ev: On<Pointer<Drag>>,
182    mut q_thumb: Query<(&ChildOf, &mut CoreScrollbarDragState), With<CoreScrollbarThumb>>,
183    mut q_scrollbar: Query<(&ComputedNode, &Scrollbar)>,
184    mut q_scroll_pos: Query<(&mut ScrollPosition, &ComputedNode), Without<Scrollbar>>,
185    ui_scale: Res<UiScale>,
186) {
187    if let Ok((ChildOf(thumb_parent), drag)) = q_thumb.get_mut(ev.entity)
188        && let Ok((node, scrollbar)) = q_scrollbar.get_mut(*thumb_parent)
189    {
190        ev.propagate(false);
191        let Ok((mut scroll_pos, scroll_content)) = q_scroll_pos.get_mut(scrollbar.target) else {
192            return;
193        };
194
195        if drag.dragging {
196            let distance = ev.event().distance / ui_scale.0;
197
198            let visible_size = (scroll_content.size() - scroll_content.scrollbar_size)
199                * scroll_content.inverse_scale_factor;
200            let content_size = scroll_content.content_size() * scroll_content.inverse_scale_factor;
201
202            let scrollbar_size = (node.size() * node.inverse_scale_factor).max(Vec2::ONE);
203
204            match scrollbar.orientation {
205                ControlOrientation::Horizontal => {
206                    let range = (content_size.x - visible_size.x).max(0.);
207                    scroll_pos.x = (drag.drag_origin
208                        + (distance.x * content_size.x) / scrollbar_size.x)
209                        .clamp(0., range);
210                }
211                ControlOrientation::Vertical => {
212                    let range = (content_size.y - visible_size.y).max(0.);
213                    scroll_pos.y = (drag.drag_origin
214                        + (distance.y * content_size.y) / scrollbar_size.y)
215                        .clamp(0., range);
216                }
217            };
218        }
219    }
220}
221
222fn scrollbar_on_drag_end(
223    mut ev: On<Pointer<DragEnd>>,
224    mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
225) {
226    if let Ok(mut drag) = q_thumb.get_mut(ev.entity) {
227        ev.propagate(false);
228        if drag.dragging {
229            drag.dragging = false;
230        }
231    }
232}
233
234fn scrollbar_on_drag_cancel(
235    mut ev: On<Pointer<Cancel>>,
236    mut q_thumb: Query<&mut CoreScrollbarDragState, With<CoreScrollbarThumb>>,
237) {
238    if let Ok(mut drag) = q_thumb.get_mut(ev.entity) {
239        ev.propagate(false);
240        if drag.dragging {
241            drag.dragging = false;
242        }
243    }
244}
245
246fn update_scrollbar_thumb(
247    q_scroll_area: Query<(&ScrollPosition, &ComputedNode)>,
248    q_scrollbar: Query<(&Scrollbar, &ComputedNode, &Children)>,
249    mut q_thumb: Query<&mut Node, With<CoreScrollbarThumb>>,
250) {
251    for (scrollbar, scrollbar_node, children) in q_scrollbar.iter() {
252        let Ok(scroll_area) = q_scroll_area.get(scrollbar.target) else {
253            continue;
254        };
255
256        // Size of the visible scrolling area.
257        let visible_size = (scroll_area.1.size() - scroll_area.1.scrollbar_size)
258            * scroll_area.1.inverse_scale_factor;
259
260        // Size of the scrolling content.
261        let content_size = scroll_area.1.content_size() * scroll_area.1.inverse_scale_factor;
262
263        // Length of the scrollbar track.
264        let track_length = scrollbar_node.size() * scrollbar_node.inverse_scale_factor;
265
266        fn size_and_pos(
267            content_size: f32,
268            visible_size: f32,
269            track_length: f32,
270            min_size: f32,
271            mut offset: f32,
272        ) -> (f32, f32) {
273            let thumb_size = if content_size > visible_size {
274                (track_length * visible_size / content_size)
275                    .max(min_size)
276                    .min(track_length)
277            } else {
278                track_length
279            };
280
281            if content_size > visible_size {
282                let max_offset = content_size - visible_size;
283
284                // Clamp offset to prevent thumb from going out of bounds during inertial scroll
285                offset = offset.clamp(0.0, max_offset);
286            } else {
287                offset = 0.0;
288            }
289
290            let thumb_pos = if content_size > visible_size {
291                offset * (track_length - thumb_size) / (content_size - visible_size)
292            } else {
293                0.
294            };
295
296            (thumb_size, thumb_pos)
297        }
298
299        for child in children {
300            if let Ok(mut thumb) = q_thumb.get_mut(*child) {
301                match scrollbar.orientation {
302                    ControlOrientation::Horizontal => {
303                        let (thumb_size, thumb_pos) = size_and_pos(
304                            content_size.x,
305                            visible_size.x,
306                            track_length.x,
307                            scrollbar.min_thumb_length,
308                            scroll_area.0.x,
309                        );
310
311                        thumb.top = Val::Px(0.);
312                        thumb.bottom = Val::Px(0.);
313                        thumb.left = Val::Px(thumb_pos);
314                        thumb.width = Val::Px(thumb_size);
315                    }
316                    ControlOrientation::Vertical => {
317                        let (thumb_size, thumb_pos) = size_and_pos(
318                            content_size.y,
319                            visible_size.y,
320                            track_length.y,
321                            scrollbar.min_thumb_length,
322                            scroll_area.0.y,
323                        );
324
325                        thumb.left = Val::Px(0.);
326                        thumb.right = Val::Px(0.);
327                        thumb.top = Val::Px(thumb_pos);
328                        thumb.height = Val::Px(thumb_size);
329                    }
330                };
331            }
332        }
333    }
334}
335
336/// Plugin that adds the observers for the [`Scrollbar`] widget.
337pub struct ScrollbarPlugin;
338
339impl Plugin for ScrollbarPlugin {
340    fn build(&self, app: &mut App) {
341        app.add_observer(scrollbar_on_pointer_down)
342            .add_observer(scrollbar_on_drag_start)
343            .add_observer(scrollbar_on_drag_end)
344            .add_observer(scrollbar_on_drag_cancel)
345            .add_observer(scrollbar_on_drag)
346            .add_systems(PostUpdate, update_scrollbar_thumb);
347    }
348}