A generic UI navigation algorithm for the Bevy engine default UI library.
[dependencies]
bevy-ui-navigation = "0.33.1"
The in-depth design specification is available here.
Check out the examples
directory for bevy examples.
This crate exposes the cuicui_dsl
feature. Disabled by default. Enabling it
will add the dsl
module, defining NavigationDsl
useable with the dsl!
macro.
This crate exposes the bevy_ui
feature. It is enabled by default. Toggling
off this feature let you compile this crate without requiring the bevy render
feature, however, it requires implementing your own input handling. Check out
the source code for the systems
module for leads on
implementing your own input handling.
This crate exposes the pointer_focus
feature. It is enabled by default.
Disabling it will remove mouse support, and remove the bevy_mod_picking
dependency.
See this example for a quick start guide.
The crate documentation is extensive, but for practical reason doesn't include many examples. This page contains most of the doc examples, you should check the examples directory for examples showcasing all features of this crate.
To create a simple menu with navigation between buttons, simply replace usages
of ButtonBundle
with FocusableButtonBundle
.
You will need to create your own system to change the color of focused elements, and add manually the input systems, but with that setup you get: Complete physical position based navigation with controller, mouse and keyboard. Including rebindable mapping.
use bevy::prelude::*;
use bevy_ui_navigation::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(DefaultNavigationPlugins)
.run();
}
Use the InputMapping
resource to change keyboard and gamepad button mapping.
If you want to change entirely how input is handled, you should do as follow. All
interaction with the navigation engine is done through
EventWriter<NavRequest>
:
use bevy::prelude::*;
use bevy_ui_navigation::prelude::*;
fn custom_input_system_emitting_nav_requests(mut events: EventWriter<NavRequest>) {
// handle input and events.send(NavRequest::FooBar)
}
fn main() {
App::new()
.add_plugins((DefaultPlugins, NavigationPlugin::new()))
.add_systems(Update, custom_input_system_emitting_nav_requests)
.run();
}
Check the examples directory
for more example code.
bevy-ui-navigation
provides a variety of ways to handle navigation actions.
Check out the NavEventReaderExt
trait
(and the NavEventReader
struct methods) for what you can do.
use bevy::{app::AppExit, prelude::*};
use bevy_ui_navigation::prelude::*;
#[derive(Component)]
enum MenuButton {
StartGame,
ToggleFullscreen,
ExitGame,
Counter(i32),
//.. etc.
}
fn handle_nav_events(
mut buttons: Query<&mut MenuButton>,
mut events: EventReader<NavEvent>,
mut exit: EventWriter<AppExit>
) {
// Note: we have a closure here because the `buttons` query is mutable.
// for immutable queries, you can use `.activated_in_query` which returns an iterator.
// Do something when player activates (click, press "A" etc.) a `Focusable` button.
events.nav_iter().activated_in_query_foreach_mut(&mut buttons, |mut button| match &mut *button {
MenuButton::StartGame => {
// start the game
}
MenuButton::ToggleFullscreen => {
// toggle fullscreen here
}
MenuButton::ExitGame => {
exit.send(AppExit);
}
MenuButton::Counter(count) => {
*count += 1;
}
//.. etc.
})
}
The focus navigation works across the whole UI tree, regardless of how or where you've put your focusable entities. You just move in the direction you want to go, and you get there.
Any Entity
can be converted into a focusable entity by adding the Focusable
component to it. To do so, just:
# use bevy::prelude::*;
# use bevy_ui_navigation::prelude::Focusable;
fn system(mut cmds: Commands, my_entity: Entity) {
cmds.entity(my_entity).insert(Focusable::default());
}
That's it! Now my_entity
is part of the navigation tree. The player can select
it with their controller the same way as any other Focusable
element.
You probably want to render the focused button differently than other buttons,
this can be done with the Changed<Focusable>
query parameter as follow:
use bevy::prelude::*;
use bevy_ui_navigation::prelude::{FocusState, Focusable};
fn button_system(
mut focusables: Query<(&Focusable, &mut BackgroundColor), Changed<Focusable>>,
) {
for (focus, mut color) in focusables.iter_mut() {
let new_color = if matches!(focus.state(), FocusState::Focused) {
Color::RED
} else {
Color::BLACK
};
*color = new_color.into();
}
}
You will want the interaction feedback to be snappy. This means the
interaction feedback should run the same frame as the focus change. For this to
happen every frame, you should add button_system
to your app using the
NavRequestSystem
label like so:
use bevy::prelude::*;
use bevy_ui_navigation::prelude::{NavRequestSystem, NavRequest, NavigationPlugin};
fn custom_mouse_input(mut events: EventWriter<NavRequest>) {
// handle input and events.send(NavRequest::FooBar)
}
fn main() {
App::new()
.add_plugins((DefaultPlugins, NavigationPlugin::new()))
// ...
.add_systems(Update, (
// Add input systems before the focus update system
custom_mouse_input.before(NavRequestSystem),
// Add the button color update system after the focus update system
button_system.after(NavRequestSystem),
))
// ...
.run();
}
// Implementation from earlier
fn button_system() {}
If you need to supress the navigation algorithm temporarily, you can declare a
Focusable
as Focusable::lock
.
This is useful for example if you want to implement custom widget with their
own controls, or if you want to disable menu navigation while in game. To
resume the navigation system, you'll need to send a NavRequest::Free
.
You can't directly manipulate which entity is focused, because we need to keep
track of a lot of thing on the backend to make the navigation work as expected.
But you can set the focused element to any arbitrary Focusable
entity with
NavRequest::FocusOn
.
use bevy::prelude::*;
use bevy_ui_navigation::prelude::NavRequest;
fn set_focus_to_arbitrary_focusable(
entity: Entity,
mut requests: EventWriter<NavRequest>,
) {
requests.send(NavRequest::FocusOn(entity));
}
You probably want to be able to chose which element is the first one to gain
focus. By default, the system picks the first Focusable
it finds. To change
this behavior, spawn a prioritized Focusable
with Focusable::prioritized
.
Suppose you have a more complex game with menus sub-menus and sub-sub-menus etc. For example, in your everyday 2021 AAA game, to change the antialiasing you would go through a few menus:
game menu → options menu → graphics menu → custom graphics menu → AA
In this case, you need to be capable of specifying which button in the previous menu leads to the next menu (for example, you would press the "Options" button in the game menu to access the options menu).
For that, you need to use MenuBuilder
.
The high level usage of MenuBuilder
is as follow:
- First you need a "root" menu using
MenuBuilder::Root
. - You need to spawn into the ECS your "options" button with a
Focusable
component. To link the button to your options menu, you need to do one of the following:- Add a
Name("opt_btn_name")
component in addition to theFocusable
component to your options button. - Pre-spawn the options button and store somewhere it's
Entity
id (let opt_btn = commands.spawn(FocusableButtonBundle).id();
)
- Add a
- to the
NodeBundle
containing all the options menuFocusable
entities, you add the following component:MenuBuilder::from_named("opt_btn_name")
if you opted for adding theName
component.MenuBuilder::EntityParent(opt_btn)
if you have anEntity
id.
In code, This will look like this:
use bevy::prelude::*;
use bevy_ui_navigation::prelude::{Focusable, MenuSetting, MenuBuilder};
use bevy_ui_navigation::components::FocusableButtonBundle;
struct SaveFile;
impl SaveFile {
fn bundle(&self) -> impl Bundle {
// UI bundle to show this in game
NodeBundle::default()
}
}
fn spawn_menu(mut cmds: Commands, save_files: Vec<SaveFile>) {
let menu_node = NodeBundle {
style: Style { flex_direction: FlexDirection::Column, ..Default::default()},
..Default::default()
};
let button = FocusableButtonBundle::from(ButtonBundle {
background_color: Color::rgb(1.0, 0.3, 1.0).into(),
..Default::default()
});
let mut spawn = |bundle: &FocusableButtonBundle, name: &'static str| {
cmds.spawn(bundle.clone()).insert(Name::new(name)).id()
};
let options = spawn(&button, "options");
let graphics_option = spawn(&button, "graphics");
let audio_options = spawn(&button, "audio");
let input_options = spawn(&button, "input");
let game = spawn(&button, "game");
let quit = spawn(&button, "quit");
let load = spawn(&button, "load");
// Spawn the game menu
cmds.spawn(menu_node.clone())
// Root Menu vvvvvvvvvvvvvvvvv
.insert((MenuSetting::new(), MenuBuilder::Root))
.push_children(&[options, game, quit, load]);
// Spawn the load menu
cmds.spawn(menu_node.clone())
// Sub menu accessible through the load button
// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
.insert((MenuSetting::new(), MenuBuilder::EntityParent(load)))
.with_children(|cmds| {
// can only access the save file UI nodes from the load menu
for file in save_files.iter() {
cmds.spawn(file.bundle()).insert(Focusable::default());
}
});
// Spawn the options menu
cmds.spawn(menu_node)
// Sub menu accessible through the "options" button
// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
.insert((MenuSetting::new(), MenuBuilder::from_named("options")))
.push_children(&[graphics_option, audio_options, input_options]);
}
With this, your game menu will be isolated from your options menu, you can only
access it by sending NavRequest::Action
when options_button
is focused, or
by sending a NavRequest::FocusOn(entity)
where entity
is any of graphics_option
, audio_options
or input_options
.
Note that you won't need to manually send the NavRequest
if you are using one
of the default input systems provided in the systems
module.
Specifically, navigation between Focusable
entities will be constrained to other
Focusable
that are children of the same MenuSetting
. It creates a self-contained
menu.
To define a menu, you need both the MenuBuilder
and MenuSetting
components.
A MenuSetting
gives you fine-grained control on how navigation is handled within a menu:
MenuSetting::new().wrapping()
enables looping navigation, where going offscreen in one direction "wraps" to the opposite screen edge.MenuSetting::new().scope()
creates a "scope" menu that catchesNavRequest::ScopeMove
requests even when the focused entity is in another sub-menu reachable from this menu. This behaves like you would expect a tabbed menu to behave.
See the MenuSetting
documentation or the "ultimate" menu navigation
example for details.
If you need to know from which menu a NavEvent::FocusChanged
originated, you
can use NavMarker
in the mark
module.
A usage demo is available in the marking.rs
example.
The default InputMapping
key to trigger menu actions is the space key.
To use the return key, change the key_action
attribute.
Otherwise, if you are not using default input handling, add this system:
use bevy::prelude::*;
use bevy_ui_navigation::prelude::{NavRequest, NavRequestSystem};
fn main() {
App::new()
// ...
.add_systems(Update, (
return_trigger_action.before(NavRequestSystem),
));
}
fn return_trigger_action(mut requests: EventWriter<NavRequest>, input: Res<Input<KeyCode>>) {
if input.just_pressed(KeyCode::Return) {
requests.send(NavRequest::Action);
}
}
See the changelog at https://github.com/nicopap/ui-navigation/blob/master/CHANGELOG.md
bevy | latest supporting version |
---|---|
0.12 | 0.33.1 |
0.11 | 0.32.0 |
0.10 | 0.24.1 |
0.9 | 0.23.1 |
0.8 | 0.21.0 |
0.7 | 0.18.0 |
0.6 | 0.14.0 |
Copyright © 2022 Nicola Papale
This software is licensed under either MIT or Apache 2.0 at your leisure. See licenses directory for details.