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(
176                                        |press: On<Pointer<Press>>, mut commands: Commands| {
177                                            if press.event().button == PointerButton::Primary {
178                                                commands.entity(press.entity).despawn();
179                                            }
180                                        },
181                                    );
182                            }
183                        });
184                });
185
186            // container for all other examples
187            parent.spawn((
188                Node {
189                    width: percent(100),
190                    height: percent(100),
191                    flex_direction: FlexDirection::Row,
192                    justify_content: JustifyContent::SpaceBetween,
193                    ..default()
194                },
195                children![
196                    vertically_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
197                    bidirectional_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
198                    bidirectional_scrolling_list_with_sticky(
199                        asset_server.load("fonts/FiraSans-Bold.ttf")
200                    ),
201                    nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
202                ],
203            ));
204        });
205}
206
207fn vertically_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
208    (
209        Node {
210            flex_direction: FlexDirection::Column,
211            justify_content: JustifyContent::Center,
212            align_items: AlignItems::Center,
213            width: px(200),
214            ..default()
215        },
216        children![
217            (
218                // Title
219                Text::new("Vertically Scrolling List"),
220                TextFont {
221                    font: font_handle.clone().into(),
222                    font_size: FONT_SIZE,
223                    ..default()
224                },
225                Label,
226            ),
227            (
228                // Scrolling list
229                Node {
230                    flex_direction: FlexDirection::Column,
231                    align_self: AlignSelf::Stretch,
232                    height: percent(50),
233                    overflow: Overflow::scroll_y(), // n.b.
234                    scrollbar_width: 20.,
235                    ..default()
236                },
237                #[cfg(feature = "bevy_ui_debug")]
238                UiDebugOptions {
239                    enabled: true,
240                    outline_border_box: false,
241                    outline_padding_box: false,
242                    outline_content_box: false,
243                    outline_scrollbars: true,
244                    line_width: 2.,
245                    line_color_override: None,
246                    show_hidden: false,
247                    show_clipped: true,
248                    ignore_border_radius: true,
249                },
250                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
251                Children::spawn(SpawnIter((0..25).map(move |i| {
252                    (
253                        Node {
254                            min_height: px(LINE_HEIGHT),
255                            max_height: px(LINE_HEIGHT),
256                            ..default()
257                        },
258                        children![(
259                            Text(format!("Item {i}")),
260                            TextFont {
261                                font: font_handle.clone().into(),
262                                ..default()
263                            },
264                            Label,
265                            AccessibilityNode(Accessible::new(Role::ListItem)),
266                        )],
267                    )
268                })))
269            ),
270        ],
271    )
272}
273
274fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
275    (
276        Node {
277            flex_direction: FlexDirection::Column,
278            justify_content: JustifyContent::Center,
279            align_items: AlignItems::Center,
280            width: px(200),
281            ..default()
282        },
283        children![
284            (
285                Text::new("Bidirectionally Scrolling List"),
286                TextFont {
287                    font: font_handle.clone().into(),
288                    font_size: FONT_SIZE,
289                    ..default()
290                },
291                Label,
292            ),
293            (
294                Node {
295                    flex_direction: FlexDirection::Column,
296                    align_self: AlignSelf::Stretch,
297                    height: percent(50),
298                    overflow: Overflow::scroll(), // n.b.
299                    ..default()
300                },
301                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
302                Children::spawn(SpawnIter((0..25).map(move |oi| {
303                    (
304                        Node {
305                            flex_direction: FlexDirection::Row,
306                            ..default()
307                        },
308                        Children::spawn(SpawnIter((0..10).map({
309                            let value = font_handle.clone();
310                            move |i| {
311                                (
312                                    Text(format!("Item {}", (oi * 10) + i)),
313                                    TextFont::from(value.clone()),
314                                    Label,
315                                    AccessibilityNode(Accessible::new(Role::ListItem)),
316                                )
317                            }
318                        }))),
319                    )
320                })))
321            )
322        ],
323    )
324}
325
326fn bidirectional_scrolling_list_with_sticky(font_handle: Handle<Font>) -> impl Bundle {
327    (
328        Node {
329            flex_direction: FlexDirection::Column,
330            justify_content: JustifyContent::Center,
331            align_items: AlignItems::Center,
332            width: px(200),
333            ..default()
334        },
335        children![
336            (
337                Text::new("Bidirectionally Scrolling List With Sticky Nodes"),
338                TextFont {
339                    font: font_handle.clone().into(),
340                    font_size: FONT_SIZE,
341                    ..default()
342                },
343                Label,
344            ),
345            (
346                Node {
347                    display: Display::Grid,
348                    align_self: AlignSelf::Stretch,
349                    height: percent(50),
350                    overflow: Overflow::scroll(), // n.b.
351                    grid_template_columns: RepeatedGridTrack::auto(30),
352                    ..default()
353                },
354                Children::spawn(SpawnIter(
355                    (0..30)
356                        .flat_map(|y| (0..30).map(move |x| (y, x)))
357                        .map(move |(y, x)| {
358                            let value = font_handle.clone();
359                            // Simple sticky nodes at top and left sides of UI node
360                            // can be achieved by combining such effects as
361                            // IgnoreScroll, ZIndex, BackgroundColor for child UI nodes.
362                            let ignore_scroll = BVec2 {
363                                x: x == 0,
364                                y: y == 0,
365                            };
366                            let (z_index, background_color, role) = match (x == 0, y == 0) {
367                                (true, true) => (2, RED, Role::RowHeader),
368                                (true, false) => (1, BLUE, Role::RowHeader),
369                                (false, true) => (1, BLUE, Role::ColumnHeader),
370                                (false, false) => (0, BLACK, Role::Cell),
371                            };
372                            (
373                                Text(format!("|{},{}|", y, x)),
374                                TextFont::from(value.clone()),
375                                TextLayout {
376                                    linebreak: LineBreak::NoWrap,
377                                    ..default()
378                                },
379                                Label,
380                                AccessibilityNode(Accessible::new(role)),
381                                IgnoreScroll(ignore_scroll),
382                                ZIndex(z_index),
383                                BackgroundColor(Color::Srgba(background_color)),
384                            )
385                        })
386                ))
387            )
388        ],
389    )
390}
391
392fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
393    (
394        Node {
395            flex_direction: FlexDirection::Column,
396            justify_content: JustifyContent::Center,
397            align_items: AlignItems::Center,
398            width: px(200),
399            ..default()
400        },
401        children![
402            (
403                // Title
404                Text::new("Nested Scrolling Lists"),
405                TextFont {
406                    font: font_handle.clone().into(),
407                    font_size: FONT_SIZE,
408                    ..default()
409                },
410                Label,
411            ),
412            (
413                // Outer, bi-directional scrolling container
414                Node {
415                    column_gap: px(20),
416                    flex_direction: FlexDirection::Row,
417                    align_self: AlignSelf::Stretch,
418                    height: percent(50),
419                    overflow: Overflow::scroll(),
420                    ..default()
421                },
422                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
423                // Inner, scrolling columns
424                Children::spawn(SpawnIter((0..5).map(move |oi| {
425                    (
426                        Node {
427                            flex_direction: FlexDirection::Column,
428                            align_self: AlignSelf::Stretch,
429                            height: percent(200. / 5. * (oi as f32 + 1.)),
430                            overflow: Overflow::scroll_y(),
431                            ..default()
432                        },
433                        BackgroundColor(Color::srgb(0.05, 0.05, 0.05)),
434                        Children::spawn(SpawnIter((0..20).map({
435                            let value = font_handle.clone();
436                            move |i| {
437                                (
438                                    Text(format!("Item {}", (oi * 20) + i)),
439                                    TextFont::from(value.clone()),
440                                    Label,
441                                    AccessibilityNode(Accessible::new(Role::ListItem)),
442                                )
443                            }
444                        }))),
445                    )
446                })))
447            )
448        ],
449    )
450}