docs/bevy/examples/ui/scroll_and_overflow/scroll.rs 16.1 K · 448 lines · raw · history

1 //! This example illustrates scrolling in Bevy UI.
2
3 use accesskit::{Node as Accessible, Role};
4 use 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
13 fn 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
24 const LINE_HEIGHT: f32 = 21.;
25
26 /// Injects scroll events into the UI hierarchy.
27 fn 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)]
55 struct Scroll {
56 entity: Entity,
57 /// Scroll delta in logical coordinates.
58 delta: Vec2,
59 }
60
61 fn 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
108 const FONT_SIZE: FontSize = FontSize::Px(20.);
109
110 fn 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
205 fn 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
272 fn 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
324 fn 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
390 fn 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 }