Skip to main content

scroll/
scroll.rs

1//! This example illustrates scrolling in Bevy UI.
2
3use accesskit::{Node as Accessible, Role};
4use bevy::{
5    a11y::AccessibilityNode,
6    color::palettes::css::{BLACK, BLUE, RED},
7    ecs::spawn::SpawnIter,
8    input::mouse::{MouseScrollUnit, MouseWheel},
9    picking::hover::HoverMap,
10    prelude::*,
11};
12
13fn main() {
14    let mut app = App::new();
15
16    app.add_plugins(DefaultPlugins)
17        .add_systems(Startup, setup)
18        .add_systems(Update, send_scroll_events)
19        .add_observer(on_scroll_handler);
20
21    app.run();
22}
23
24const LINE_HEIGHT: f32 = 21.;
25
26/// Injects scroll events into the UI hierarchy.
27fn send_scroll_events(
28    mut mouse_wheel_reader: MessageReader<MouseWheel>,
29    hover_map: Res<HoverMap>,
30    keyboard_input: Res<ButtonInput<KeyCode>>,
31    mut commands: Commands,
32) {
33    for mouse_wheel in mouse_wheel_reader.read() {
34        let mut delta = -Vec2::new(mouse_wheel.x, mouse_wheel.y);
35
36        if mouse_wheel.unit == MouseScrollUnit::Line {
37            delta *= LINE_HEIGHT;
38        }
39
40        if keyboard_input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) {
41            std::mem::swap(&mut delta.x, &mut delta.y);
42        }
43
44        for pointer_map in hover_map.values() {
45            for entity in pointer_map.keys().copied() {
46                commands.trigger(Scroll { entity, delta });
47            }
48        }
49    }
50}
51
52/// UI scrolling event.
53#[derive(EntityEvent, Debug)]
54#[entity_event(propagate, auto_propagate)]
55struct Scroll {
56    entity: Entity,
57    /// Scroll delta in logical coordinates.
58    delta: Vec2,
59}
60
61fn on_scroll_handler(
62    mut scroll: On<Scroll>,
63    mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>,
64) {
65    let Ok((mut scroll_position, node, computed)) = query.get_mut(scroll.entity) else {
66        return;
67    };
68
69    let max_offset = (computed.content_size() - computed.size()) * computed.inverse_scale_factor();
70
71    let delta = &mut scroll.delta;
72    if node.overflow.x == OverflowAxis::Scroll && delta.x != 0. {
73        // Is this node already scrolled all the way in the direction of the scroll?
74        let max = if delta.x > 0. {
75            scroll_position.x >= max_offset.x
76        } else {
77            scroll_position.x <= 0.
78        };
79
80        if !max {
81            scroll_position.x += delta.x;
82            // Consume the X portion of the scroll delta.
83            delta.x = 0.;
84        }
85    }
86
87    if node.overflow.y == OverflowAxis::Scroll && delta.y != 0. {
88        // Is this node already scrolled all the way in the direction of the scroll?
89        let max = if delta.y > 0. {
90            scroll_position.y >= max_offset.y
91        } else {
92            scroll_position.y <= 0.
93        };
94
95        if !max {
96            scroll_position.y += delta.y;
97            // Consume the Y portion of the scroll delta.
98            delta.y = 0.;
99        }
100    }
101
102    // Stop propagating when the delta is fully consumed.
103    if *delta == Vec2::ZERO {
104        scroll.propagate(false);
105    }
106}
107
108const FONT_SIZE: FontSize = FontSize::Px(20.);
109
110fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
111    // Camera
112    commands.spawn((Camera2d, IsDefaultUiCamera));
113
114    // Font
115    let font_handle = asset_server.load("fonts/FiraSans-Bold.ttf");
116
117    // root node
118    commands
119        .spawn(Node {
120            width: percent(100),
121            height: percent(100),
122            justify_content: JustifyContent::SpaceBetween,
123            flex_direction: FlexDirection::Column,
124            ..default()
125        })
126        .with_children(|parent| {
127            // horizontal scroll example
128            parent
129                .spawn(Node {
130                    width: percent(100),
131                    flex_direction: FlexDirection::Column,
132                    ..default()
133                })
134                .with_children(|parent| {
135                    // header
136                    parent.spawn((
137                        Text::new("Horizontally Scrolling list (Ctrl + MouseWheel)"),
138                        TextFont {
139                            font: font_handle.clone().into(),
140                            font_size: FONT_SIZE,
141                            ..default()
142                        },
143                        Label,
144                    ));
145
146                    // horizontal scroll container
147                    parent
148                        .spawn((
149                            Node {
150                                width: percent(80),
151                                margin: UiRect::all(px(10)),
152                                flex_direction: FlexDirection::Row,
153                                overflow: Overflow::scroll_x(), // n.b.
154                                ..default()
155                            },
156                            BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
157                        ))
158                        .with_children(|parent| {
159                            for i in 0..100 {
160                                parent
161                                    .spawn((
162                                        Text(format!("Item {i}")),
163                                        TextFont {
164                                            font: font_handle.clone().into(),
165                                            ..default()
166                                        },
167                                        Label,
168                                        AccessibilityNode(Accessible::new(Role::ListItem)),
169                                        Node {
170                                            min_width: px(200),
171                                            align_content: AlignContent::Center,
172                                            ..default()
173                                        },
174                                    ))
175                                    .observe(|press: On<PointerPress>, mut commands: Commands| {
176                                        if press.event().button == PointerButton::Primary {
177                                            commands.entity(press.entity).despawn();
178                                        }
179                                    });
180                            }
181                        });
182                });
183
184            // container for all other examples
185            parent.spawn((
186                Node {
187                    width: percent(100),
188                    height: percent(100),
189                    flex_direction: FlexDirection::Row,
190                    justify_content: JustifyContent::SpaceBetween,
191                    ..default()
192                },
193                children![
194                    vertically_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
195                    bidirectional_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
196                    bidirectional_scrolling_list_with_sticky(
197                        asset_server.load("fonts/FiraSans-Bold.ttf")
198                    ),
199                    nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
200                ],
201            ));
202        });
203}
204
205fn vertically_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
206    (
207        Node {
208            flex_direction: FlexDirection::Column,
209            justify_content: JustifyContent::Center,
210            align_items: AlignItems::Center,
211            width: px(200),
212            ..default()
213        },
214        children![
215            (
216                // Title
217                Text::new("Vertically Scrolling List"),
218                TextFont {
219                    font: font_handle.clone().into(),
220                    font_size: FONT_SIZE,
221                    ..default()
222                },
223                Label,
224            ),
225            (
226                // Scrolling list
227                Node {
228                    flex_direction: FlexDirection::Column,
229                    align_self: AlignSelf::Stretch,
230                    height: percent(50),
231                    overflow: Overflow::scroll_y(), // n.b.
232                    scrollbar_width: 20.,
233                    ..default()
234                },
235                #[cfg(feature = "bevy_ui_debug")]
236                UiDebugOptions {
237                    enabled: true,
238                    outline_border_box: false,
239                    outline_padding_box: false,
240                    outline_content_box: false,
241                    outline_scrollbars: true,
242                    line_width: 2.,
243                    line_color_override: None,
244                    show_hidden: false,
245                    show_clipped: true,
246                    ignore_border_radius: true,
247                },
248                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
249                Children::spawn(SpawnIter((0..25).map(move |i| {
250                    (
251                        Node {
252                            min_height: px(LINE_HEIGHT),
253                            max_height: px(LINE_HEIGHT),
254                            ..default()
255                        },
256                        children![(
257                            Text(format!("Item {i}")),
258                            TextFont {
259                                font: font_handle.clone().into(),
260                                ..default()
261                            },
262                            Label,
263                            AccessibilityNode(Accessible::new(Role::ListItem)),
264                        )],
265                    )
266                })))
267            ),
268        ],
269    )
270}
271
272fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
273    (
274        Node {
275            flex_direction: FlexDirection::Column,
276            justify_content: JustifyContent::Center,
277            align_items: AlignItems::Center,
278            width: px(200),
279            ..default()
280        },
281        children![
282            (
283                Text::new("Bidirectionally Scrolling List"),
284                TextFont {
285                    font: font_handle.clone().into(),
286                    font_size: FONT_SIZE,
287                    ..default()
288                },
289                Label,
290            ),
291            (
292                Node {
293                    flex_direction: FlexDirection::Column,
294                    align_self: AlignSelf::Stretch,
295                    height: percent(50),
296                    overflow: Overflow::scroll(), // n.b.
297                    ..default()
298                },
299                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
300                Children::spawn(SpawnIter((0..25).map(move |oi| {
301                    (
302                        Node {
303                            flex_direction: FlexDirection::Row,
304                            ..default()
305                        },
306                        Children::spawn(SpawnIter((0..10).map({
307                            let value = font_handle.clone();
308                            move |i| {
309                                (
310                                    Text(format!("Item {}", (oi * 10) + i)),
311                                    TextFont::from(value.clone()),
312                                    Label,
313                                    AccessibilityNode(Accessible::new(Role::ListItem)),
314                                )
315                            }
316                        }))),
317                    )
318                })))
319            )
320        ],
321    )
322}
323
324fn bidirectional_scrolling_list_with_sticky(font_handle: Handle<Font>) -> impl Bundle {
325    (
326        Node {
327            flex_direction: FlexDirection::Column,
328            justify_content: JustifyContent::Center,
329            align_items: AlignItems::Center,
330            width: px(200),
331            ..default()
332        },
333        children![
334            (
335                Text::new("Bidirectionally Scrolling List With Sticky Nodes"),
336                TextFont {
337                    font: font_handle.clone().into(),
338                    font_size: FONT_SIZE,
339                    ..default()
340                },
341                Label,
342            ),
343            (
344                Node {
345                    display: Display::Grid,
346                    align_self: AlignSelf::Stretch,
347                    height: percent(50),
348                    overflow: Overflow::scroll(), // n.b.
349                    grid_template_columns: RepeatedGridTrack::auto(30),
350                    ..default()
351                },
352                Children::spawn(SpawnIter(
353                    (0..30)
354                        .flat_map(|y| (0..30).map(move |x| (y, x)))
355                        .map(move |(y, x)| {
356                            let value = font_handle.clone();
357                            // Simple sticky nodes at top and left sides of UI node
358                            // can be achieved by combining such effects as
359                            // IgnoreScroll, ZIndex, BackgroundColor for child UI nodes.
360                            let ignore_scroll = BVec2 {
361                                x: x == 0,
362                                y: y == 0,
363                            };
364                            let (z_index, background_color, role) = match (x == 0, y == 0) {
365                                (true, true) => (2, RED, Role::RowHeader),
366                                (true, false) => (1, BLUE, Role::RowHeader),
367                                (false, true) => (1, BLUE, Role::ColumnHeader),
368                                (false, false) => (0, BLACK, Role::Cell),
369                            };
370                            (
371                                Text(format!("|{},{}|", y, x)),
372                                TextFont::from(value.clone()),
373                                TextLayout {
374                                    linebreak: LineBreak::NoWrap,
375                                    ..default()
376                                },
377                                Label,
378                                AccessibilityNode(Accessible::new(role)),
379                                IgnoreScroll(ignore_scroll),
380                                ZIndex(z_index),
381                                BackgroundColor(Color::Srgba(background_color)),
382                            )
383                        })
384                ))
385            )
386        ],
387    )
388}
389
390fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
391    (
392        Node {
393            flex_direction: FlexDirection::Column,
394            justify_content: JustifyContent::Center,
395            align_items: AlignItems::Center,
396            width: px(200),
397            ..default()
398        },
399        children![
400            (
401                // Title
402                Text::new("Nested Scrolling Lists"),
403                TextFont {
404                    font: font_handle.clone().into(),
405                    font_size: FONT_SIZE,
406                    ..default()
407                },
408                Label,
409            ),
410            (
411                // Outer, bi-directional scrolling container
412                Node {
413                    column_gap: px(20),
414                    flex_direction: FlexDirection::Row,
415                    align_self: AlignSelf::Stretch,
416                    height: percent(50),
417                    overflow: Overflow::scroll(),
418                    ..default()
419                },
420                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
421                // Inner, scrolling columns
422                Children::spawn(SpawnIter((0..5).map(move |oi| {
423                    (
424                        Node {
425                            flex_direction: FlexDirection::Column,
426                            align_self: AlignSelf::Stretch,
427                            height: percent(200. / 5. * (oi as f32 + 1.)),
428                            overflow: Overflow::scroll_y(),
429                            ..default()
430                        },
431                        BackgroundColor(Color::srgb(0.05, 0.05, 0.05)),
432                        Children::spawn(SpawnIter((0..20).map({
433                            let value = font_handle.clone();
434                            move |i| {
435                                (
436                                    Text(format!("Item {}", (oi * 20) + i)),
437                                    TextFont::from(value.clone()),
438                                    Label,
439                                    AccessibilityNode(Accessible::new(Role::ListItem)),
440                                )
441                            }
442                        }))),
443                    )
444                })))
445            )
446        ],
447    )
448}