scroll/
scroll.rs

1//! This example illustrates scrolling in Bevy UI.
2
3use accesskit::{Node as Accessible, Role};
4use bevy::{
5    a11y::AccessibilityNode,
6    ecs::spawn::SpawnIter,
7    input::mouse::{MouseScrollUnit, MouseWheel},
8    picking::hover::HoverMap,
9    prelude::*,
10};
11
12fn main() {
13    let mut app = App::new();
14    app.add_plugins(DefaultPlugins)
15        .add_systems(Startup, setup)
16        .add_systems(Update, send_scroll_events)
17        .add_observer(on_scroll_handler);
18
19    app.run();
20}
21
22const LINE_HEIGHT: f32 = 21.;
23
24/// Injects scroll events into the UI hierarchy.
25fn send_scroll_events(
26    mut mouse_wheel_reader: MessageReader<MouseWheel>,
27    hover_map: Res<HoverMap>,
28    keyboard_input: Res<ButtonInput<KeyCode>>,
29    mut commands: Commands,
30) {
31    for mouse_wheel in mouse_wheel_reader.read() {
32        let mut delta = -Vec2::new(mouse_wheel.x, mouse_wheel.y);
33
34        if mouse_wheel.unit == MouseScrollUnit::Line {
35            delta *= LINE_HEIGHT;
36        }
37
38        if keyboard_input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) {
39            std::mem::swap(&mut delta.x, &mut delta.y);
40        }
41
42        for pointer_map in hover_map.values() {
43            for entity in pointer_map.keys().copied() {
44                commands.trigger(Scroll { entity, delta });
45            }
46        }
47    }
48}
49
50/// UI scrolling event.
51#[derive(EntityEvent, Debug)]
52#[entity_event(propagate, auto_propagate)]
53struct Scroll {
54    entity: Entity,
55    /// Scroll delta in logical coordinates.
56    delta: Vec2,
57}
58
59fn on_scroll_handler(
60    mut scroll: On<Scroll>,
61    mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>,
62) {
63    let Ok((mut scroll_position, node, computed)) = query.get_mut(scroll.entity) else {
64        return;
65    };
66
67    let max_offset = (computed.content_size() - computed.size()) * computed.inverse_scale_factor();
68
69    let delta = &mut scroll.delta;
70    if node.overflow.x == OverflowAxis::Scroll && delta.x != 0. {
71        // Is this node already scrolled all the way in the direction of the scroll?
72        let max = if delta.x > 0. {
73            scroll_position.x >= max_offset.x
74        } else {
75            scroll_position.x <= 0.
76        };
77
78        if !max {
79            scroll_position.x += delta.x;
80            // Consume the X portion of the scroll delta.
81            delta.x = 0.;
82        }
83    }
84
85    if node.overflow.y == OverflowAxis::Scroll && delta.y != 0. {
86        // Is this node already scrolled all the way in the direction of the scroll?
87        let max = if delta.y > 0. {
88            scroll_position.y >= max_offset.y
89        } else {
90            scroll_position.y <= 0.
91        };
92
93        if !max {
94            scroll_position.y += delta.y;
95            // Consume the Y portion of the scroll delta.
96            delta.y = 0.;
97        }
98    }
99
100    // Stop propagating when the delta is fully consumed.
101    if *delta == Vec2::ZERO {
102        scroll.propagate(false);
103    }
104}
105
106const FONT_SIZE: f32 = 20.;
107
108fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
109    // Camera
110    commands.spawn((Camera2d, IsDefaultUiCamera));
111
112    // Font
113    let font_handle = asset_server.load("fonts/FiraSans-Bold.ttf");
114
115    // root node
116    commands
117        .spawn(Node {
118            width: percent(100),
119            height: percent(100),
120            justify_content: JustifyContent::SpaceBetween,
121            flex_direction: FlexDirection::Column,
122            ..default()
123        })
124        .with_children(|parent| {
125            // horizontal scroll example
126            parent
127                .spawn(Node {
128                    width: percent(100),
129                    flex_direction: FlexDirection::Column,
130                    ..default()
131                })
132                .with_children(|parent| {
133                    // header
134                    parent.spawn((
135                        Text::new("Horizontally Scrolling list (Ctrl + MouseWheel)"),
136                        TextFont {
137                            font: font_handle.clone(),
138                            font_size: FONT_SIZE,
139                            ..default()
140                        },
141                        Label,
142                    ));
143
144                    // horizontal scroll container
145                    parent
146                        .spawn((
147                            Node {
148                                width: percent(80),
149                                margin: UiRect::all(px(10)),
150                                flex_direction: FlexDirection::Row,
151                                overflow: Overflow::scroll_x(), // n.b.
152                                ..default()
153                            },
154                            BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
155                        ))
156                        .with_children(|parent| {
157                            for i in 0..100 {
158                                parent
159                                    .spawn((
160                                        Text(format!("Item {i}")),
161                                        TextFont {
162                                            font: font_handle.clone(),
163                                            ..default()
164                                        },
165                                        Label,
166                                        AccessibilityNode(Accessible::new(Role::ListItem)),
167                                        Node {
168                                            min_width: px(200),
169                                            align_content: AlignContent::Center,
170                                            ..default()
171                                        },
172                                    ))
173                                    .observe(
174                                        |press: On<Pointer<Press>>, mut commands: Commands| {
175                                            if press.event().button == PointerButton::Primary {
176                                                commands.entity(press.entity).despawn();
177                                            }
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                    nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
197                ],
198            ));
199        });
200}
201
202fn vertically_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
203    (
204        Node {
205            flex_direction: FlexDirection::Column,
206            justify_content: JustifyContent::Center,
207            align_items: AlignItems::Center,
208            width: px(200),
209            ..default()
210        },
211        children![
212            (
213                // Title
214                Text::new("Vertically Scrolling List"),
215                TextFont {
216                    font: font_handle.clone(),
217                    font_size: FONT_SIZE,
218                    ..default()
219                },
220                Label,
221            ),
222            (
223                // Scrolling list
224                Node {
225                    flex_direction: FlexDirection::Column,
226                    align_self: AlignSelf::Stretch,
227                    height: percent(50),
228                    overflow: Overflow::scroll_y(), // n.b.
229                    ..default()
230                },
231                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
232                Children::spawn(SpawnIter((0..25).map(move |i| {
233                    (
234                        Node {
235                            min_height: px(LINE_HEIGHT),
236                            max_height: px(LINE_HEIGHT),
237                            ..default()
238                        },
239                        children![(
240                            Text(format!("Item {i}")),
241                            TextFont {
242                                font: font_handle.clone(),
243                                ..default()
244                            },
245                            Label,
246                            AccessibilityNode(Accessible::new(Role::ListItem)),
247                        )],
248                    )
249                })))
250            ),
251        ],
252    )
253}
254
255fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
256    (
257        Node {
258            flex_direction: FlexDirection::Column,
259            justify_content: JustifyContent::Center,
260            align_items: AlignItems::Center,
261            width: px(200),
262            ..default()
263        },
264        children![
265            (
266                Text::new("Bidirectionally Scrolling List"),
267                TextFont {
268                    font: font_handle.clone(),
269                    font_size: FONT_SIZE,
270                    ..default()
271                },
272                Label,
273            ),
274            (
275                Node {
276                    flex_direction: FlexDirection::Column,
277                    align_self: AlignSelf::Stretch,
278                    height: percent(50),
279                    overflow: Overflow::scroll(), // n.b.
280                    ..default()
281                },
282                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
283                Children::spawn(SpawnIter((0..25).map(move |oi| {
284                    (
285                        Node {
286                            flex_direction: FlexDirection::Row,
287                            ..default()
288                        },
289                        Children::spawn(SpawnIter((0..10).map({
290                            let value = font_handle.clone();
291                            move |i| {
292                                (
293                                    Text(format!("Item {}", (oi * 10) + i)),
294                                    TextFont {
295                                        font: value.clone(),
296                                        ..default()
297                                    },
298                                    Label,
299                                    AccessibilityNode(Accessible::new(Role::ListItem)),
300                                )
301                            }
302                        }))),
303                    )
304                })))
305            )
306        ],
307    )
308}
309
310fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
311    (
312        Node {
313            flex_direction: FlexDirection::Column,
314            justify_content: JustifyContent::Center,
315            align_items: AlignItems::Center,
316            width: px(200),
317            ..default()
318        },
319        children![
320            (
321                // Title
322                Text::new("Nested Scrolling Lists"),
323                TextFont {
324                    font: font_handle.clone(),
325                    font_size: FONT_SIZE,
326                    ..default()
327                },
328                Label,
329            ),
330            (
331                // Outer, bi-directional scrolling container
332                Node {
333                    column_gap: px(20),
334                    flex_direction: FlexDirection::Row,
335                    align_self: AlignSelf::Stretch,
336                    height: percent(50),
337                    overflow: Overflow::scroll(),
338                    ..default()
339                },
340                BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
341                // Inner, scrolling columns
342                Children::spawn(SpawnIter((0..5).map(move |oi| {
343                    (
344                        Node {
345                            flex_direction: FlexDirection::Column,
346                            align_self: AlignSelf::Stretch,
347                            height: percent(200. / 5. * (oi as f32 + 1.)),
348                            overflow: Overflow::scroll_y(),
349                            ..default()
350                        },
351                        BackgroundColor(Color::srgb(0.05, 0.05, 0.05)),
352                        Children::spawn(SpawnIter((0..20).map({
353                            let value = font_handle.clone();
354                            move |i| {
355                                (
356                                    Text(format!("Item {}", (oi * 20) + i)),
357                                    TextFont {
358                                        font: value.clone(),
359                                        ..default()
360                                    },
361                                    Label,
362                                    AccessibilityNode(Accessible::new(Role::ListItem)),
363                                )
364                            }
365                        }))),
366                    )
367                })))
368            )
369        ],
370    )
371}