docs/bevy/examples/ui/navigation/directional_navigation.rs 16.3 K · 493 lines · raw · history

1 //! Demonstrates automatic directional navigation.
2 //!
3 //! This shows how to use automatic navigation by simply adding the [`AutoDirectionalNavigation`]
4 //! component to UI elements. Navigation is automatically calculated based on screen positions.
5 //!
6 //! This is especially useful for:
7 //! - Dynamic UIs where elements may be added, removed, or repositioned
8 //! - Irregular layouts that don't fit a simple grid pattern
9 //! - Prototyping where you want navigation without tedious manual setup
10 //!
11 //! The automatic system finds the nearest neighbor in each compass direction for every node,
12 //! completely eliminating the need to manually specify navigation relationships.
13 //!
14 //! For an example that demonstrates automatic directional navigation with manual overrides,
15 //! refer to the `directional_navigation_overrides` example.
16
17 use core::time::Duration;
18
19 use bevy::{
20 camera::NormalizedRenderTarget,
21 input_focus::{
22 directional_navigation::{AutoNavigationConfig, DirectionalNavigationPlugin},
23 tab_navigation::TabIndex,
24 AutoFocus, InputFocus, InputFocusVisible,
25 },
26 math::{CompassOctant, Dir2, Rot2},
27 picking::{
28 backend::HitData,
29 pointer::{Location, PointerId},
30 },
31 platform::collections::HashSet,
32 prelude::*,
33 ui::auto_directional_navigation::{AutoDirectionalNavigation, AutoDirectionalNavigator},
34 ui_widgets::Button,
35 };
36
37 fn main() {
38 App::new()
39 .add_plugins((DefaultPlugins, DirectionalNavigationPlugin))
40 // This resource is canonically used to track whether or not to render a focus indicator
41 // It starts as false, but we set it to true here as we would like to see the focus indicator
42 .insert_resource(InputFocusVisible(true))
43 // Configure auto-navigation behavior
44 .insert_resource(AutoNavigationConfig {
45 // Require at least 10% overlap in perpendicular axis for cardinal directions
46 min_alignment_factor: 0.1,
47 // Don't connect nodes more than 500 pixels apart between their closest edges
48 max_search_distance: Some(500.0),
49 // Prefer nodes that are well-aligned
50 prefer_aligned: true,
51 })
52 .init_resource::<ActionState>()
53 .add_systems(Startup, (setup_scattered_ui, init_focus).chain())
54 // No manual system needed - just add AutoDirectionalNavigation to entities.
55 // Input is generally handled during PreUpdate
56 .add_systems(PreUpdate, (process_inputs, navigate).chain())
57 .add_systems(
58 Update,
59 (
60 highlight_focused_element,
61 interact_with_focused_button,
62 reset_button_after_interaction,
63 update_focus_display
64 .run_if(|input_focus: Res<InputFocus>| input_focus.is_changed()),
65 update_key_display,
66 ),
67 )
68 .add_observer(universal_button_click_behavior)
69 .run();
70 }
71
72 const NORMAL_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_400;
73 const PRESSED_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_500;
74 const FOCUSED_BORDER: Srgba = bevy::color::palettes::tailwind::BLUE_50;
75
76 /// Marker component for the text that displays the currently focused button
77 #[derive(Component, Clone, Default)]
78 struct FocusDisplay;
79
80 /// Marker component for the text that displays the last key pressed
81 #[derive(Component, Clone, Default)]
82 struct KeyDisplay;
83
84 // Observer for button clicks. Clicking on a button is considered navigation to that button.
85 // This is also triggered when `interact_with_focused_button` simulates a button click with a
86 // manually sent pointer click event
87 fn universal_button_click_behavior(
88 mut click: On<PointerClick>,
89 mut button_query: Query<(&mut BackgroundColor, &mut ResetTimer)>,
90 mut focus_visible: ResMut<InputFocusVisible>,
91 ) {
92 let button_entity = click.entity;
93 if let Ok((mut color, mut reset_timer)) = button_query.get_mut(button_entity) {
94 color.0 = PRESSED_BUTTON.into();
95 reset_timer.0 = Timer::from_seconds(0.3, TimerMode::Once);
96 click.propagate(false);
97 // The focus should be visible because a button was clicked.
98 // It may have been set to false by the `PointerFocusPlugin`
99 focus_visible.0 = true;
100 }
101 }
102
103 #[derive(Clone, Component, Default, Deref, DerefMut)]
104 struct ResetTimer(Timer);
105
106 fn reset_button_after_interaction(
107 time: Res<Time>,
108 mut query: Query<(&mut ResetTimer, &mut BackgroundColor)>,
109 ) {
110 for (mut reset_timer, mut color) in query.iter_mut() {
111 reset_timer.tick(time.delta());
112 if reset_timer.just_finished() {
113 color.0 = NORMAL_BUTTON.into();
114 }
115 }
116 }
117
118 /// Spawn a scattered layout of buttons to demonstrate automatic navigation.
119 ///
120 /// Unlike a regular grid, these buttons are irregularly positioned,
121 /// but auto-navigation will still figure out the correct connections!
122 fn setup_scattered_ui(mut commands: Commands) {
123 commands.spawn(Camera2d);
124
125 commands.spawn_scene(bsn! {
126 Node {
127 width: percent(100),
128 height: percent(100),
129 }
130 Children [
131 @instructions_scene()
132 --
133 @focus_display_scene()
134 --
135 @key_display_scene()
136 --
137 { buttons_scene() }
138 ]
139 });
140 }
141
142 /// Initializes focus to "Button 1" by giving it the `AutoFocus` component.
143 fn init_focus(mut commands: Commands, button_query: Query<(Entity, &Name), With<Button>>) {
144 for (entity, name) in button_query.iter() {
145 if name.as_str() == "Button 1" {
146 commands.entity(entity).insert(AutoFocus);
147 break;
148 }
149 }
150 }
151
152 fn instructions_scene() -> impl Scene {
153 bsn! {
154 Node {
155 position_type: PositionType::Absolute,
156 left: px(20),
157 top: px(20),
158 width: px(280),
159 padding: UiRect::all(px(12)),
160 border_radius: BorderRadius::all(px(8)),
161 }
162 BackgroundColor(Color::srgba(0.1, 0.1, 0.1, 0.8))
163 Children [
164 Text(
165 "Directional Navigation Demo\n\n\
166 Use arrow keys or D-pad to navigate.\n\
167 Press Enter or A button to interact.\n\n\
168 Buttons are scattered irregularly,\n\
169 but navigation is automatic!",
170 )
171 ]
172 }
173 }
174
175 fn focus_display_scene() -> impl Scene {
176 bsn! {
177 Node {
178 position_type: PositionType::Absolute,
179 left: px(20),
180 bottom: px(80),
181 width: px(280),
182 padding: UiRect::all(px(12)),
183 border_radius: BorderRadius::all(px(8)),
184 }
185 BackgroundColor(Color::srgba(0.1, 0.5, 0.1, 0.8))
186 Children [
187 FocusDisplay
188 Text("Focused: None")
189 TextFont {
190 font_size: FontSize::Px(20.0),
191 }
192 ]
193 }
194 }
195
196 fn key_display_scene() -> impl Scene {
197 bsn! {
198 Node {
199 position_type: PositionType::Absolute,
200 left: px(20),
201 bottom: px(20),
202 width: px(280),
203 padding: UiRect::all(px(12)),
204 border_radius: BorderRadius::all(px(8)),
205 }
206 BackgroundColor(Color::srgba(0.5, 0.1, 0.5, 0.8))
207 Children [
208 KeyDisplay
209 Text("Last Key: None")
210 TextFont {
211 font_size: FontSize::Px(20.0),
212 }
213 ]
214 }
215 }
216
217 fn buttons_scene() -> impl SceneList {
218 // Spawn buttons in a scattered/irregular pattern
219 // The auto-navigation system will figure out the connections!
220 let button_positions = [
221 // Top row (irregular spacing)
222 (350.0, 100.0),
223 (520.0, 120.0),
224 (700.0, 90.0),
225 // Middle-top row
226 (380.0, 220.0),
227 (600.0, 240.0),
228 // Center
229 (450.0, 340.0),
230 (620.0, 360.0),
231 // Lower row
232 (360.0, 480.0),
233 (540.0, 460.0),
234 (720.0, 490.0),
235 ];
236
237 button_positions
238 .iter()
239 .enumerate()
240 .map(|(i, (x, y))| {
241 let transform = if i == 4 {
242 UiTransform {
243 scale: Vec2::splat(1.2),
244 rotation: Rot2::FRAC_PI_2,
245 ..default()
246 }
247 } else {
248 UiTransform::IDENTITY
249 };
250 Box::new(bsn! {
251 Name::new(format!("Button {}", i + 1))
252 Button
253 // This ensures that navigating to this button via a click ensures the
254 // focus is correctly set to this button.
255 TabIndex(0)
256 Node {
257 position_type: PositionType::Absolute,
258 left: px(*x),
259 top: px(*y),
260 width: px(140),
261 height: px(80),
262 border: UiRect::all(px(4)),
263 justify_content: JustifyContent::Center,
264 align_items: AlignItems::Center,
265 border_radius: BorderRadius::all(px(12)),
266 }
267 transform
268 // This is the key: just add this component for automatic navigation!
269 AutoDirectionalNavigation::default()
270 ResetTimer::default()
271 BackgroundColor::from(NORMAL_BUTTON)
272 Children [
273 Text(format!("Button {}", i + 1))
274 TextLayout {
275 justify: Justify::Center,
276 }
277 ]
278 })
279 })
280 .collect::<Vec<_>>()
281 }
282
283 // Action state and input handling
284 #[derive(Debug, PartialEq, Eq, Hash)]
285 enum DirectionalNavigationAction {
286 Up,
287 Down,
288 Left,
289 Right,
290 Select,
291 }
292
293 impl DirectionalNavigationAction {
294 fn variants() -> Vec<Self> {
295 vec![
296 DirectionalNavigationAction::Up,
297 DirectionalNavigationAction::Down,
298 DirectionalNavigationAction::Left,
299 DirectionalNavigationAction::Right,
300 DirectionalNavigationAction::Select,
301 ]
302 }
303
304 fn keycode(&self) -> KeyCode {
305 match self {
306 DirectionalNavigationAction::Up => KeyCode::ArrowUp,
307 DirectionalNavigationAction::Down => KeyCode::ArrowDown,
308 DirectionalNavigationAction::Left => KeyCode::ArrowLeft,
309 DirectionalNavigationAction::Right => KeyCode::ArrowRight,
310 DirectionalNavigationAction::Select => KeyCode::Enter,
311 }
312 }
313
314 fn gamepad_button(&self) -> GamepadButton {
315 match self {
316 DirectionalNavigationAction::Up => GamepadButton::DPadUp,
317 DirectionalNavigationAction::Down => GamepadButton::DPadDown,
318 DirectionalNavigationAction::Left => GamepadButton::DPadLeft,
319 DirectionalNavigationAction::Right => GamepadButton::DPadRight,
320 DirectionalNavigationAction::Select => GamepadButton::South,
321 }
322 }
323 }
324
325 #[derive(Default, Resource)]
326 struct ActionState {
327 pressed_actions: HashSet<DirectionalNavigationAction>,
328 }
329
330 fn process_inputs(
331 mut action_state: ResMut<ActionState>,
332 keyboard_input: Res<ButtonInput<KeyCode>>,
333 gamepad_input: Query<&Gamepad>,
334 ) {
335 action_state.pressed_actions.clear();
336
337 for action in DirectionalNavigationAction::variants() {
338 if keyboard_input.just_pressed(action.keycode()) {
339 action_state.pressed_actions.insert(action);
340 }
341 }
342
343 for gamepad in gamepad_input.iter() {
344 for action in DirectionalNavigationAction::variants() {
345 if gamepad.just_pressed(action.gamepad_button()) {
346 action_state.pressed_actions.insert(action);
347 }
348 }
349 }
350 }
351
352 fn navigate(
353 action_state: Res<ActionState>,
354 mut auto_directional_navigator: AutoDirectionalNavigator,
355 ) {
356 let net_east_west = action_state
357 .pressed_actions
358 .contains(&DirectionalNavigationAction::Right) as i8
359 - action_state
360 .pressed_actions
361 .contains(&DirectionalNavigationAction::Left) as i8;
362
363 let net_north_south = action_state
364 .pressed_actions
365 .contains(&DirectionalNavigationAction::Up) as i8
366 - action_state
367 .pressed_actions
368 .contains(&DirectionalNavigationAction::Down) as i8;
369
370 // Use Dir2::from_xy to convert input to direction, then convert to CompassOctant
371 let maybe_direction = Dir2::from_xy(net_east_west as f32, net_north_south as f32)
372 .ok()
373 .map(CompassOctant::from);
374
375 if let Some(direction) = maybe_direction {
376 match auto_directional_navigator.navigate(direction) {
377 Ok(_entity) => {
378 // Successfully navigated
379 }
380 Err(_e) => {
381 // Navigation failed (no neighbor in that direction)
382 }
383 }
384 }
385 }
386
387 fn update_focus_display(
388 input_focus: Res<InputFocus>,
389 button_query: Query<&Name, With<Button>>,
390 mut display_query: Query<&mut Text, With<FocusDisplay>>,
391 ) {
392 if let Ok(mut text) = display_query.single_mut() {
393 if let Some(focused_entity) = input_focus.get() {
394 if let Ok(name) = button_query.get(focused_entity) {
395 **text = format!("Focused: {}", name);
396 } else {
397 **text = "Focused: Unknown".to_string();
398 }
399 } else {
400 **text = "Focused: None".to_string();
401 }
402 }
403 }
404
405 fn update_key_display(
406 keyboard_input: Res<ButtonInput<KeyCode>>,
407 gamepad_input: Query<&Gamepad>,
408 mut display_query: Query<&mut Text, With<KeyDisplay>>,
409 ) {
410 if let Ok(mut text) = display_query.single_mut() {
411 // Check for keyboard inputs
412 for action in DirectionalNavigationAction::variants() {
413 if keyboard_input.just_pressed(action.keycode()) {
414 let key_name = match action {
415 DirectionalNavigationAction::Up => "Up Arrow",
416 DirectionalNavigationAction::Down => "Down Arrow",
417 DirectionalNavigationAction::Left => "Left Arrow",
418 DirectionalNavigationAction::Right => "Right Arrow",
419 DirectionalNavigationAction::Select => "Enter",
420 };
421 **text = format!("Last Key: {}", key_name);
422 return;
423 }
424 }
425
426 // Check for gamepad inputs
427 for gamepad in gamepad_input.iter() {
428 for action in DirectionalNavigationAction::variants() {
429 if gamepad.just_pressed(action.gamepad_button()) {
430 let button_name = match action {
431 DirectionalNavigationAction::Up => "D-Pad Up",
432 DirectionalNavigationAction::Down => "D-Pad Down",
433 DirectionalNavigationAction::Left => "D-Pad Left",
434 DirectionalNavigationAction::Right => "D-Pad Right",
435 DirectionalNavigationAction::Select => "A Button",
436 };
437 **text = format!("Last Key: {}", button_name);
438 return;
439 }
440 }
441 }
442 }
443 }
444
445 fn highlight_focused_element(
446 input_focus: Res<InputFocus>,
447 input_focus_visible: Res<InputFocusVisible>,
448 mut query: Query<(Entity, &mut BorderColor)>,
449 ) {
450 for (entity, mut border_color) in query.iter_mut() {
451 if input_focus.get() == Some(entity) && input_focus_visible.0 {
452 *border_color = BorderColor::all(FOCUSED_BORDER);
453 } else {
454 *border_color = BorderColor::DEFAULT;
455 }
456 }
457 }
458
459 fn interact_with_focused_button(
460 action_state: Res<ActionState>,
461 input_focus: Res<InputFocus>,
462 mut commands: Commands,
463 ) {
464 if action_state
465 .pressed_actions
466 .contains(&DirectionalNavigationAction::Select)
467 && let Some(focused_entity) = input_focus.get()
468 {
469 commands.trigger(PointerClick {
470 entity: focused_entity,
471 pointer: Pointer::new(
472 PointerId::Mouse,
473 Location {
474 target: NormalizedRenderTarget::None {
475 width: 0,
476 height: 0,
477 },
478 position: Vec2::ZERO,
479 },
480 ),
481 button: PointerButton::Primary,
482 hit: HitData {
483 camera: Entity::PLACEHOLDER,
484 depth: 0.0,
485 position: None,
486 normal: None,
487 extra: None,
488 },
489 count: 1,
490 duration: Duration::from_secs_f32(0.1),
491 });
492 }
493 }