Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/examples/ui/directional_navigation.rs
6595 views
1
//! Demonstrates how to set up the directional navigation system to allow for navigation between widgets.
2
//!
3
//! Directional navigation is generally used to move between widgets in a user interface using arrow keys or gamepad input.
4
//! When compared to tab navigation, directional navigation is generally more direct, and less aware of the structure of the UI.
5
//!
6
//! In this example, we will set up a simple UI with a grid of buttons that can be navigated using the arrow keys or gamepad input.
7
8
use std::time::Duration;
9
10
use bevy::{
11
camera::NormalizedRenderTarget,
12
input_focus::{
13
directional_navigation::{
14
DirectionalNavigation, DirectionalNavigationMap, DirectionalNavigationPlugin,
15
},
16
InputDispatchPlugin, InputFocus, InputFocusVisible,
17
},
18
math::{CompassOctant, FloatOrd},
19
picking::{
20
backend::HitData,
21
pointer::{Location, PointerId},
22
},
23
platform::collections::{HashMap, HashSet},
24
prelude::*,
25
};
26
27
fn main() {
28
App::new()
29
// Input focus is not enabled by default, so we need to add the corresponding plugins
30
.add_plugins((
31
DefaultPlugins,
32
InputDispatchPlugin,
33
DirectionalNavigationPlugin,
34
))
35
// This resource is canonically used to track whether or not to render a focus indicator
36
// It starts as false, but we set it to true here as we would like to see the focus indicator
37
.insert_resource(InputFocusVisible(true))
38
// We've made a simple resource to keep track of the actions that are currently being pressed for this example
39
.init_resource::<ActionState>()
40
.add_systems(Startup, setup_ui)
41
// Input is generally handled during PreUpdate
42
// We're turning inputs into actions first, then using those actions to determine navigation
43
.add_systems(PreUpdate, (process_inputs, navigate).chain())
44
.add_systems(
45
Update,
46
(
47
// We need to show which button is currently focused
48
highlight_focused_element,
49
// Pressing the "Interact" button while we have a focused element should simulate a click
50
interact_with_focused_button,
51
// We're doing a tiny animation when the button is interacted with,
52
// so we need a timer and a polling mechanism to reset it
53
reset_button_after_interaction,
54
),
55
)
56
// This observer is added globally, so it will respond to *any* trigger of the correct type.
57
// However, we're filtering in the observer's query to only respond to button presses
58
.add_observer(universal_button_click_behavior)
59
.run();
60
}
61
62
const NORMAL_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_400;
63
const PRESSED_BUTTON: Srgba = bevy::color::palettes::tailwind::BLUE_500;
64
const FOCUSED_BORDER: Srgba = bevy::color::palettes::tailwind::BLUE_50;
65
66
// This observer will be triggered whenever a button is pressed
67
// In a real project, each button would also have its own unique behavior,
68
// to capture the actual intent of the user
69
fn universal_button_click_behavior(
70
mut event: On<Pointer<Click>>,
71
mut button_query: Query<(&mut BackgroundColor, &mut ResetTimer)>,
72
) {
73
let button_entity = event.entity();
74
if let Ok((mut color, mut reset_timer)) = button_query.get_mut(button_entity) {
75
// This would be a great place to play a little sound effect too!
76
color.0 = PRESSED_BUTTON.into();
77
reset_timer.0 = Timer::from_seconds(0.3, TimerMode::Once);
78
79
// Picking events propagate up the hierarchy,
80
// so we need to stop the propagation here now that we've handled it
81
event.propagate(false);
82
}
83
}
84
85
/// Resets a UI element to its default state when the timer has elapsed.
86
#[derive(Component, Default, Deref, DerefMut)]
87
struct ResetTimer(Timer);
88
89
fn reset_button_after_interaction(
90
time: Res<Time>,
91
mut query: Query<(&mut ResetTimer, &mut BackgroundColor)>,
92
) {
93
for (mut reset_timer, mut color) in query.iter_mut() {
94
reset_timer.tick(time.delta());
95
if reset_timer.just_finished() {
96
color.0 = NORMAL_BUTTON.into();
97
}
98
}
99
}
100
101
// We're spawning a simple grid of buttons and some instructions
102
// The buttons are just colored rectangles with text displaying the button's name
103
fn setup_ui(
104
mut commands: Commands,
105
mut directional_nav_map: ResMut<DirectionalNavigationMap>,
106
mut input_focus: ResMut<InputFocus>,
107
) {
108
const N_ROWS: u16 = 5;
109
const N_COLS: u16 = 3;
110
111
// Rendering UI elements requires a camera
112
commands.spawn(Camera2d);
113
114
// Create a full-screen background node
115
let root_node = commands
116
.spawn(Node {
117
width: percent(100),
118
height: percent(100),
119
..default()
120
})
121
.id();
122
123
// Add instruction to the left of the grid
124
let instructions = commands
125
.spawn((
126
Text::new("Use arrow keys or D-pad to navigate. \
127
Click the buttons, or press Enter / the South gamepad button to interact with the focused button."),
128
Node {
129
width: px(300),
130
justify_content: JustifyContent::Center,
131
align_items: AlignItems::Center,
132
margin: UiRect::all(px(12)),
133
..default()
134
},
135
))
136
.id();
137
138
// Set up the root entity to hold the grid
139
let grid_root_entity = commands
140
.spawn(Node {
141
display: Display::Grid,
142
// Allow the grid to take up the full height and the rest of the width of the window
143
width: percent(100),
144
height: percent(100),
145
// Set the number of rows and columns in the grid
146
// allowing the grid to automatically size the cells
147
grid_template_columns: RepeatedGridTrack::auto(N_COLS),
148
grid_template_rows: RepeatedGridTrack::auto(N_ROWS),
149
..default()
150
})
151
.id();
152
153
// Add the instructions and grid to the root node
154
commands
155
.entity(root_node)
156
.add_children(&[instructions, grid_root_entity]);
157
158
let mut button_entities: HashMap<(u16, u16), Entity> = HashMap::default();
159
for row in 0..N_ROWS {
160
for col in 0..N_COLS {
161
let button_name = format!("Button {row}-{col}");
162
163
let button_entity = commands
164
.spawn((
165
Button,
166
Node {
167
width: px(200),
168
height: px(120),
169
// Add a border so we can show which element is focused
170
border: UiRect::all(px(4)),
171
// Center the button's text label
172
justify_content: JustifyContent::Center,
173
align_items: AlignItems::Center,
174
// Center the button within the grid cell
175
align_self: AlignSelf::Center,
176
justify_self: JustifySelf::Center,
177
..default()
178
},
179
ResetTimer::default(),
180
BorderRadius::all(px(16)),
181
BackgroundColor::from(NORMAL_BUTTON),
182
Name::new(button_name.clone()),
183
))
184
// Add a text element to the button
185
.with_child((
186
Text::new(button_name),
187
// And center the text if it flows onto multiple lines
188
TextLayout {
189
justify: Justify::Center,
190
..default()
191
},
192
))
193
.id();
194
195
// Add the button to the grid
196
commands.entity(grid_root_entity).add_child(button_entity);
197
198
// Keep track of the button entities so we can set up our navigation graph
199
button_entities.insert((row, col), button_entity);
200
}
201
}
202
203
// Connect all of the buttons in the same row to each other,
204
// looping around when the edge is reached.
205
for row in 0..N_ROWS {
206
let entities_in_row: Vec<Entity> = (0..N_COLS)
207
.map(|col| button_entities.get(&(row, col)).unwrap())
208
.copied()
209
.collect();
210
directional_nav_map.add_looping_edges(&entities_in_row, CompassOctant::East);
211
}
212
213
// Connect all of the buttons in the same column to each other,
214
// but don't loop around when the edge is reached.
215
// While looping is a very reasonable choice, we're not doing it here to demonstrate the different options.
216
for col in 0..N_COLS {
217
let entities_in_column: Vec<Entity> = (0..N_ROWS)
218
.map(|row| button_entities.get(&(row, col)).unwrap())
219
.copied()
220
.collect();
221
222
directional_nav_map.add_edges(&entities_in_column, CompassOctant::South);
223
}
224
225
// When changing scenes, remember to set an initial focus!
226
let top_left_entity = *button_entities.get(&(0, 0)).unwrap();
227
input_focus.set(top_left_entity);
228
}
229
230
// The indirection between inputs and actions allows us to easily remap inputs
231
// and handle multiple input sources (keyboard, gamepad, etc.) in our game
232
#[derive(Debug, PartialEq, Eq, Hash)]
233
enum DirectionalNavigationAction {
234
Up,
235
Down,
236
Left,
237
Right,
238
Select,
239
}
240
241
impl DirectionalNavigationAction {
242
fn variants() -> Vec<Self> {
243
vec![
244
DirectionalNavigationAction::Up,
245
DirectionalNavigationAction::Down,
246
DirectionalNavigationAction::Left,
247
DirectionalNavigationAction::Right,
248
DirectionalNavigationAction::Select,
249
]
250
}
251
252
fn keycode(&self) -> KeyCode {
253
match self {
254
DirectionalNavigationAction::Up => KeyCode::ArrowUp,
255
DirectionalNavigationAction::Down => KeyCode::ArrowDown,
256
DirectionalNavigationAction::Left => KeyCode::ArrowLeft,
257
DirectionalNavigationAction::Right => KeyCode::ArrowRight,
258
DirectionalNavigationAction::Select => KeyCode::Enter,
259
}
260
}
261
262
fn gamepad_button(&self) -> GamepadButton {
263
match self {
264
DirectionalNavigationAction::Up => GamepadButton::DPadUp,
265
DirectionalNavigationAction::Down => GamepadButton::DPadDown,
266
DirectionalNavigationAction::Left => GamepadButton::DPadLeft,
267
DirectionalNavigationAction::Right => GamepadButton::DPadRight,
268
// This is the "A" button on an Xbox controller,
269
// and is conventionally used as the "Select" / "Interact" button in many games
270
DirectionalNavigationAction::Select => GamepadButton::South,
271
}
272
}
273
}
274
275
// This keeps track of the inputs that are currently being pressed
276
#[derive(Default, Resource)]
277
struct ActionState {
278
pressed_actions: HashSet<DirectionalNavigationAction>,
279
}
280
281
fn process_inputs(
282
mut action_state: ResMut<ActionState>,
283
keyboard_input: Res<ButtonInput<KeyCode>>,
284
gamepad_input: Query<&Gamepad>,
285
) {
286
// Reset the set of pressed actions each frame
287
// to ensure that we only process each action once
288
action_state.pressed_actions.clear();
289
290
for action in DirectionalNavigationAction::variants() {
291
// Use just_pressed to ensure that we only process each action once
292
// for each time it is pressed
293
if keyboard_input.just_pressed(action.keycode()) {
294
action_state.pressed_actions.insert(action);
295
}
296
}
297
298
// We're treating this like a single-player game:
299
// if multiple gamepads are connected, we don't care which one is being used
300
for gamepad in gamepad_input.iter() {
301
for action in DirectionalNavigationAction::variants() {
302
// Unlike keyboard input, gamepads are bound to a specific controller
303
if gamepad.just_pressed(action.gamepad_button()) {
304
action_state.pressed_actions.insert(action);
305
}
306
}
307
}
308
}
309
310
fn navigate(action_state: Res<ActionState>, mut directional_navigation: DirectionalNavigation) {
311
// If the user is pressing both left and right, or up and down,
312
// we should not move in either direction.
313
let net_east_west = action_state
314
.pressed_actions
315
.contains(&DirectionalNavigationAction::Right) as i8
316
- action_state
317
.pressed_actions
318
.contains(&DirectionalNavigationAction::Left) as i8;
319
320
let net_north_south = action_state
321
.pressed_actions
322
.contains(&DirectionalNavigationAction::Up) as i8
323
- action_state
324
.pressed_actions
325
.contains(&DirectionalNavigationAction::Down) as i8;
326
327
// Compute the direction that the user is trying to navigate in
328
let maybe_direction = match (net_east_west, net_north_south) {
329
(0, 0) => None,
330
(0, 1) => Some(CompassOctant::North),
331
(1, 1) => Some(CompassOctant::NorthEast),
332
(1, 0) => Some(CompassOctant::East),
333
(1, -1) => Some(CompassOctant::SouthEast),
334
(0, -1) => Some(CompassOctant::South),
335
(-1, -1) => Some(CompassOctant::SouthWest),
336
(-1, 0) => Some(CompassOctant::West),
337
(-1, 1) => Some(CompassOctant::NorthWest),
338
_ => None,
339
};
340
341
if let Some(direction) = maybe_direction {
342
match directional_navigation.navigate(direction) {
343
// In a real game, you would likely want to play a sound or show a visual effect
344
// on both successful and unsuccessful navigation attempts
345
Ok(entity) => {
346
println!("Navigated {direction:?} successfully. {entity} is now focused.");
347
}
348
Err(e) => println!("Navigation failed: {e}"),
349
}
350
}
351
}
352
353
fn highlight_focused_element(
354
input_focus: Res<InputFocus>,
355
// While this isn't strictly needed for the example,
356
// we're demonstrating how to be a good citizen by respecting the `InputFocusVisible` resource.
357
input_focus_visible: Res<InputFocusVisible>,
358
mut query: Query<(Entity, &mut BorderColor)>,
359
) {
360
for (entity, mut border_color) in query.iter_mut() {
361
if input_focus.0 == Some(entity) && input_focus_visible.0 {
362
// Don't change the border size / radius here,
363
// as it would result in wiggling buttons when they are focused
364
*border_color = BorderColor::all(FOCUSED_BORDER);
365
} else {
366
*border_color = BorderColor::DEFAULT;
367
}
368
}
369
}
370
371
// By sending a Pointer<Click> trigger rather than directly handling button-like interactions,
372
// we can unify our handling of pointer and keyboard/gamepad interactions
373
fn interact_with_focused_button(
374
action_state: Res<ActionState>,
375
input_focus: Res<InputFocus>,
376
mut commands: Commands,
377
) {
378
if action_state
379
.pressed_actions
380
.contains(&DirectionalNavigationAction::Select)
381
&& let Some(focused_entity) = input_focus.0
382
{
383
commands.trigger_targets(
384
Pointer::<Click> {
385
// We're pretending that we're a mouse
386
pointer_id: PointerId::Mouse,
387
// This field isn't used, so we're just setting it to a placeholder value
388
pointer_location: Location {
389
target: NormalizedRenderTarget::Image(bevy::camera::ImageRenderTarget {
390
handle: Handle::default(),
391
scale_factor: FloatOrd(1.0),
392
}),
393
position: Vec2::ZERO,
394
},
395
event: Click {
396
button: PointerButton::Primary,
397
// This field isn't used, so we're just setting it to a placeholder value
398
hit: HitData {
399
camera: Entity::PLACEHOLDER,
400
depth: 0.0,
401
position: None,
402
normal: None,
403
},
404
duration: Duration::from_secs_f32(0.1),
405
},
406
},
407
focused_entity,
408
);
409
}
410
}
411
412