Core slider (#19584)

# Objective

This is part of the "core widgets" effort: #19236. 

## Solution

This PR adds the "core slider" widget to the collection.

## Testing

Tested using examples `core_widgets` and `core_widgets_observers`.

---------

Co-authored-by: ickshonpe <david.curthoys@googlemail.com>
This commit is contained in:
Talin 2025-06-14 17:53:31 -07:00 committed by GitHub
parent f47b1c00ee
commit 30aa36eaf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1044 additions and 86 deletions

View File

@ -15,8 +15,14 @@ bevy_a11y = { path = "../bevy_a11y", version = "0.16.0-dev" }
bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev" }
bevy_input = { path = "../bevy_input", version = "0.16.0-dev" }
bevy_input_focus = { path = "../bevy_input_focus", version = "0.16.0-dev" }
bevy_log = { path = "../bevy_log", version = "0.16.0-dev" }
bevy_math = { path = "../bevy_math", version = "0.16.0-dev" }
bevy_picking = { path = "../bevy_picking", version = "0.16.0-dev" }
bevy_ui = { path = "../bevy_ui", version = "0.16.0-dev" }
bevy_render = { path = "../bevy_render", version = "0.16.0-dev" }
bevy_transform = { path = "../bevy_transform", version = "0.16.0-dev" }
bevy_ui = { path = "../bevy_ui", version = "0.16.0-dev", features = [
"bevy_ui_picking_backend",
] }
# other
accesskit = "0.19"

View File

@ -0,0 +1,509 @@
use core::ops::RangeInclusive;
use accesskit::{Orientation, Role};
use bevy_a11y::AccessibilityNode;
use bevy_app::{App, Plugin};
use bevy_ecs::event::Event;
use bevy_ecs::hierarchy::{ChildOf, Children};
use bevy_ecs::lifecycle::Insert;
use bevy_ecs::query::Has;
use bevy_ecs::system::{In, Res, ResMut};
use bevy_ecs::world::DeferredWorld;
use bevy_ecs::{
component::Component,
observer::On,
query::With,
system::{Commands, Query, SystemId},
};
use bevy_input::keyboard::{KeyCode, KeyboardInput};
use bevy_input::ButtonState;
use bevy_input_focus::{FocusedInput, InputFocus, InputFocusVisible};
use bevy_log::warn_once;
use bevy_picking::events::{Drag, DragEnd, DragStart, Pointer, Press};
use bevy_ui::{ComputedNode, ComputedNodeTarget, InteractionDisabled, UiGlobalTransform, UiScale};
/// Defines how the slider should behave when you click on the track (not the thumb).
#[derive(Debug, Default)]
pub enum TrackClick {
/// Clicking on the track lets you drag to edit the value, just like clicking on the thumb.
#[default]
Drag,
/// Clicking on the track increments or decrements the slider by [`SliderStep`].
Step,
/// Clicking on the track snaps the value to the clicked position.
Snap,
}
/// A headless slider widget, which can be used to build custom sliders. Sliders have a value
/// (represented by the [`SliderValue`] component) and a range (represented by [`SliderRange`]). An
/// optional step size can be specified via [`SliderStep`].
///
/// You can also control the slider remotely by triggering a [`SetSliderValue`] event on it. This
/// can be useful in a console environment for controlling the value gamepad inputs.
///
/// The presence of the `on_change` property controls whether the slider uses internal or external
/// state management. If the `on_change` property is `None`, then the slider updates its own state
/// automatically. Otherwise, the `on_change` property contains the id of a one-shot system which is
/// passed the new slider value. In this case, the slider value is not modified, it is the
/// responsibility of the callback to trigger whatever data-binding mechanism is used to update the
/// slider's value.
///
/// Typically a slider will contain entities representing the "track" and "thumb" elements. The core
/// slider makes no assumptions about the hierarchical structure of these elements, but expects that
/// the thumb will be marked with a [`CoreSliderThumb`] component.
///
/// The core slider does not modify the visible position of the thumb: that is the responsibility of
/// the stylist. This can be done either in percent or pixel units as desired. To prevent overhang
/// at the ends of the slider, the positioning should take into account the thumb width, by reducing
/// the amount of travel. So for example, in a slider 100px wide, with a thumb that is 10px, the
/// amount of travel is 90px. The core slider's calculations for clicking and dragging assume this
/// is the case, and will reduce the travel by the measured size of the thumb entity, which allows
/// the movement of the thumb to be perfectly synchronized with the movement of the mouse.
///
/// In cases where overhang is desired for artistic reasons, the thumb may have additional
/// decorative child elements, absolutely positioned, which don't affect the size measurement.
#[derive(Component, Debug, Default)]
#[require(
AccessibilityNode(accesskit::Node::new(Role::Slider)),
CoreSliderDragState,
SliderValue,
SliderRange,
SliderStep
)]
pub struct CoreSlider {
/// Callback which is called when the slider is dragged or the value is changed via other user
/// interaction. If this value is `None`, then the slider will self-update.
pub on_change: Option<SystemId<In<f32>>>,
/// Set the track-clicking behavior for this slider.
pub track_click: TrackClick,
// TODO: Think about whether we want a "vertical" option.
}
/// Marker component that identifies which descendant element is the slider thumb.
#[derive(Component, Debug, Default)]
pub struct CoreSliderThumb;
/// A component which stores the current value of the slider.
#[derive(Component, Debug, Default, PartialEq, Clone, Copy)]
#[component(immutable)]
pub struct SliderValue(pub f32);
/// A component which represents the allowed range of the slider value. Defaults to 0.0..=1.0.
#[derive(Component, Debug, PartialEq, Clone, Copy)]
#[component(immutable)]
pub struct SliderRange {
start: f32,
end: f32,
}
impl SliderRange {
/// Creates a new slider range with the given start and end values.
pub fn new(start: f32, end: f32) -> Self {
if end < start {
warn_once!(
"Expected SliderRange::start ({}) <= SliderRange::end ({})",
start,
end
);
}
Self { start, end }
}
/// Creates a new slider range from a Rust range.
pub fn from_range(range: RangeInclusive<f32>) -> Self {
let (start, end) = range.into_inner();
Self { start, end }
}
/// Returns the minimum allowed value for this slider.
pub fn start(&self) -> f32 {
self.start
}
/// Return a new instance of a `SliderRange` with a new start position.
pub fn with_start(&self, start: f32) -> Self {
Self::new(start, self.end)
}
/// Returns the maximum allowed value for this slider.
pub fn end(&self) -> f32 {
self.end
}
/// Return a new instance of a `SliderRange` with a new end position.
pub fn with_end(&self, end: f32) -> Self {
Self::new(self.start, end)
}
/// Returns the full span of the range (max - min).
pub fn span(&self) -> f32 {
self.end - self.start
}
/// Returns the center value of the range.
pub fn center(&self) -> f32 {
(self.start + self.end) / 2.0
}
/// Constrain a value between the minimum and maximum allowed values for this slider.
pub fn clamp(&self, value: f32) -> f32 {
value.clamp(self.start, self.end)
}
/// Compute the position of the thumb on the slider, as a value between 0 and 1, taking
/// into account the proportion of the value between the minimum and maximum limits.
pub fn thumb_position(&self, value: f32) -> f32 {
if self.end > self.start {
(value - self.start) / (self.end - self.start)
} else {
0.5
}
}
}
impl Default for SliderRange {
fn default() -> Self {
Self {
start: 0.0,
end: 1.0,
}
}
}
/// Defines the amount by which to increment or decrement the slider value when using keyboard
/// shorctuts. Defaults to 1.0.
#[derive(Component, Debug, PartialEq, Clone)]
#[component(immutable)]
pub struct SliderStep(pub f32);
impl Default for SliderStep {
fn default() -> Self {
Self(1.0)
}
}
/// Component used to manage the state of a slider during dragging.
#[derive(Component, Default)]
pub struct CoreSliderDragState {
/// Whether the slider is currently being dragged.
pub dragging: bool,
/// The value of the slider when dragging started.
offset: f32,
}
pub(crate) fn slider_on_pointer_down(
mut trigger: On<Pointer<Press>>,
q_slider: Query<(
&CoreSlider,
&SliderValue,
&SliderRange,
&SliderStep,
&ComputedNode,
&ComputedNodeTarget,
&UiGlobalTransform,
Has<InteractionDisabled>,
)>,
q_thumb: Query<&ComputedNode, With<CoreSliderThumb>>,
q_children: Query<&Children>,
q_parents: Query<&ChildOf>,
focus: Option<ResMut<InputFocus>>,
focus_visible: Option<ResMut<InputFocusVisible>>,
mut commands: Commands,
) {
if q_thumb.contains(trigger.target().unwrap()) {
// Thumb click, stop propagation to prevent track click.
trigger.propagate(false);
// Find the slider entity that's an ancestor of the thumb
if let Some(slider_entity) = q_parents
.iter_ancestors(trigger.target().unwrap())
.find(|entity| q_slider.contains(*entity))
{
// Set focus to slider and hide focus ring
if let Some(mut focus) = focus {
focus.0 = Some(slider_entity);
}
if let Some(mut focus_visible) = focus_visible {
focus_visible.0 = false;
}
}
} else if let Ok((slider, value, range, step, node, node_target, transform, disabled)) =
q_slider.get(trigger.target().unwrap())
{
// Track click
trigger.propagate(false);
// Set focus to slider and hide focus ring
if let Some(mut focus) = focus {
focus.0 = trigger.target();
}
if let Some(mut focus_visible) = focus_visible {
focus_visible.0 = false;
}
if disabled {
return;
}
// Find thumb size by searching descendants for the first entity with CoreSliderThumb
let thumb_size = q_children
.iter_descendants(trigger.target().unwrap())
.find_map(|child_id| q_thumb.get(child_id).ok().map(|thumb| thumb.size().x))
.unwrap_or(0.0);
// Detect track click.
let local_pos = transform.try_inverse().unwrap().transform_point2(
trigger.event().pointer_location.position * node_target.scale_factor(),
);
let track_width = node.size().x - thumb_size;
// Avoid division by zero
let click_val = if track_width > 0. {
local_pos.x * range.span() / track_width + range.center()
} else {
0.
};
// Compute new value from click position
let new_value = range.clamp(match slider.track_click {
TrackClick::Drag => {
return;
}
TrackClick::Step => {
if click_val < value.0 {
value.0 - step.0
} else {
value.0 + step.0
}
}
TrackClick::Snap => click_val,
});
if let Some(on_change) = slider.on_change {
commands.run_system_with(on_change, new_value);
} else {
commands
.entity(trigger.target().unwrap())
.insert(SliderValue(new_value));
}
}
}
pub(crate) fn slider_on_drag_start(
mut trigger: On<Pointer<DragStart>>,
mut q_slider: Query<
(
&SliderValue,
&mut CoreSliderDragState,
Has<InteractionDisabled>,
),
With<CoreSlider>,
>,
) {
if let Ok((value, mut drag, disabled)) = q_slider.get_mut(trigger.target().unwrap()) {
trigger.propagate(false);
if !disabled {
drag.dragging = true;
drag.offset = value.0;
}
}
}
pub(crate) fn slider_on_drag(
mut trigger: On<Pointer<Drag>>,
mut q_slider: Query<(
&ComputedNode,
&CoreSlider,
&SliderRange,
&UiGlobalTransform,
&mut CoreSliderDragState,
Has<InteractionDisabled>,
)>,
q_thumb: Query<&ComputedNode, With<CoreSliderThumb>>,
q_children: Query<&Children>,
mut commands: Commands,
ui_scale: Res<UiScale>,
) {
if let Ok((node, slider, range, transform, drag, disabled)) =
q_slider.get_mut(trigger.target().unwrap())
{
trigger.propagate(false);
if drag.dragging && !disabled {
let mut distance = trigger.event().distance / ui_scale.0;
distance.y *= -1.;
let distance = transform.transform_vector2(distance);
// Find thumb size by searching descendants for the first entity with CoreSliderThumb
let thumb_size = q_children
.iter_descendants(trigger.target().unwrap())
.find_map(|child_id| q_thumb.get(child_id).ok().map(|thumb| thumb.size().x))
.unwrap_or(0.0);
let slider_width = ((node.size().x - thumb_size) * node.inverse_scale_factor).max(1.0);
let span = range.span();
let new_value = if span > 0. {
range.clamp(drag.offset + (distance.x * span) / slider_width)
} else {
range.start() + span * 0.5
};
if let Some(on_change) = slider.on_change {
commands.run_system_with(on_change, new_value);
} else {
commands
.entity(trigger.target().unwrap())
.insert(SliderValue(new_value));
}
}
}
}
pub(crate) fn slider_on_drag_end(
mut trigger: On<Pointer<DragEnd>>,
mut q_slider: Query<(&CoreSlider, &mut CoreSliderDragState)>,
) {
if let Ok((_slider, mut drag)) = q_slider.get_mut(trigger.target().unwrap()) {
trigger.propagate(false);
if drag.dragging {
drag.dragging = false;
}
}
}
fn slider_on_key_input(
mut trigger: On<FocusedInput<KeyboardInput>>,
q_slider: Query<(
&CoreSlider,
&SliderValue,
&SliderRange,
&SliderStep,
Has<InteractionDisabled>,
)>,
mut commands: Commands,
) {
if let Ok((slider, value, range, step, disabled)) = q_slider.get(trigger.target().unwrap()) {
let event = &trigger.event().input;
if !disabled && event.state == ButtonState::Pressed {
let new_value = match event.key_code {
KeyCode::ArrowLeft => range.clamp(value.0 - step.0),
KeyCode::ArrowRight => range.clamp(value.0 + step.0),
KeyCode::Home => range.start(),
KeyCode::End => range.end(),
_ => {
return;
}
};
trigger.propagate(false);
if let Some(on_change) = slider.on_change {
commands.run_system_with(on_change, new_value);
}
}
}
}
pub(crate) fn slider_on_insert(trigger: On<Insert, CoreSlider>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(trigger.target().unwrap());
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_orientation(Orientation::Horizontal);
}
}
pub(crate) fn slider_on_insert_value(trigger: On<Insert, SliderValue>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(trigger.target().unwrap());
let value = entity.get::<SliderValue>().unwrap().0;
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_numeric_value(value.into());
}
}
pub(crate) fn slider_on_insert_range(trigger: On<Insert, SliderRange>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(trigger.target().unwrap());
let range = *entity.get::<SliderRange>().unwrap();
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_min_numeric_value(range.start().into());
accessibility.set_max_numeric_value(range.end().into());
}
}
pub(crate) fn slider_on_insert_step(trigger: On<Insert, SliderStep>, mut world: DeferredWorld) {
let mut entity = world.entity_mut(trigger.target().unwrap());
let step = entity.get::<SliderStep>().unwrap().0;
if let Some(mut accessibility) = entity.get_mut::<AccessibilityNode>() {
accessibility.set_numeric_value_step(step.into());
}
}
/// Event which can be triggered on a slider to modify the value (using the `on_change` callback).
/// This can be used to control the slider via gamepad buttons or other inputs. The value will be
/// clamped when the event is processed.
///
/// # Example:
///
/// ```
/// use bevy_ecs::system::Commands;
/// use bevy_core_widgets::{CoreSlider, SliderRange, SliderValue, SetSliderValue};
///
/// fn setup(mut commands: Commands) {
/// // Create a slider
/// let slider = commands.spawn((
/// CoreSlider::default(),
/// SliderValue(0.5),
/// SliderRange::new(0.0, 1.0),
/// )).id();
///
/// // Set to an absolute value
/// commands.trigger_targets(SetSliderValue::Absolute(0.75), slider);
///
/// // Adjust relatively
/// commands.trigger_targets(SetSliderValue::Relative(-0.25), slider);
/// }
/// ```
#[derive(Event)]
pub enum SetSliderValue {
/// Set the slider value to a specific value.
Absolute(f32),
/// Add a delta to the slider value.
Relative(f32),
/// Add a delta to the slider value, multiplied by the step size.
RelativeStep(f32),
}
fn slider_on_set_value(
mut trigger: On<SetSliderValue>,
q_slider: Query<(&CoreSlider, &SliderValue, &SliderRange, Option<&SliderStep>)>,
mut commands: Commands,
) {
if let Ok((slider, value, range, step)) = q_slider.get(trigger.target().unwrap()) {
trigger.propagate(false);
let new_value = match trigger.event() {
SetSliderValue::Absolute(new_value) => range.clamp(*new_value),
SetSliderValue::Relative(delta) => range.clamp(value.0 + *delta),
SetSliderValue::RelativeStep(delta) => {
range.clamp(value.0 + *delta * step.map(|s| s.0).unwrap_or_default())
}
};
if let Some(on_change) = slider.on_change {
commands.run_system_with(on_change, new_value);
} else {
commands
.entity(trigger.target().unwrap())
.insert(SliderValue(new_value));
}
}
}
/// Plugin that adds the observers for the [`CoreSlider`] widget.
pub struct CoreSliderPlugin;
impl Plugin for CoreSliderPlugin {
fn build(&self, app: &mut App) {
app.add_observer(slider_on_pointer_down)
.add_observer(slider_on_drag_start)
.add_observer(slider_on_drag_end)
.add_observer(slider_on_drag)
.add_observer(slider_on_key_input)
.add_observer(slider_on_insert)
.add_observer(slider_on_insert_value)
.add_observer(slider_on_insert_range)
.add_observer(slider_on_insert_step)
.add_observer(slider_on_set_value);
}
}

View File

@ -10,11 +10,20 @@
//! widget. The primary motivation for this is to avoid two-way data binding in scenarios where the
//! user interface is showing a live view of dynamic data coming from deeper within the game engine.
// Note on naming: the `Core` prefix is used on components that would normally be internal to the
// styled/opinionated widgets that use them. Components which are directly exposed to users above
// the widget level, like `SliderValue`, should not have the `Core` prefix.
mod core_button;
mod core_slider;
use bevy_app::{App, Plugin};
pub use core_button::{CoreButton, CoreButtonPlugin};
pub use core_slider::{
CoreSlider, CoreSliderDragState, CoreSliderPlugin, CoreSliderThumb, SetSliderValue,
SliderRange, SliderStep, SliderValue, TrackClick,
};
/// A plugin that registers the observers for all of the core widgets. If you don't want to
/// use all of the widgets, you can import the individual widget plugins instead.
@ -22,6 +31,6 @@ pub struct CoreWidgetsPlugin;
impl Plugin for CoreWidgetsPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(CoreButtonPlugin);
app.add_plugins((CoreButtonPlugin, CoreSliderPlugin));
}
}

View File

@ -2,7 +2,10 @@
use bevy::{
color::palettes::basic::*,
core_widgets::{CoreButton, CoreWidgetsPlugin},
core_widgets::{
CoreButton, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue,
TrackClick,
},
ecs::system::SystemId,
input_focus::{
tab_navigation::{TabGroup, TabIndex},
@ -19,10 +22,18 @@ fn main() {
.add_plugins((DefaultPlugins, CoreWidgetsPlugin, InputDispatchPlugin))
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
.insert_resource(WinitSettings::desktop_app())
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
.add_systems(Startup, setup)
.add_systems(
Update,
(update_button_style, update_button_style2, toggle_disabled),
(
update_widget_values,
update_button_style,
update_button_style2,
update_slider_style.after(update_widget_values),
update_slider_style2.after(update_widget_values),
toggle_disabled,
),
)
.run();
}
@ -30,11 +41,32 @@ fn main() {
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05);
const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35);
/// Marker which identifies buttons with a particular style, in this case the "Demo style".
#[derive(Component)]
struct DemoButton;
/// Marker which identifies sliders with a particular style.
#[derive(Component, Default)]
struct DemoSlider;
/// Marker which identifies the slider's thumb element.
#[derive(Component, Default)]
struct DemoSliderThumb;
/// A struct to hold the state of various widgets shown in the demo.
///
/// While it is possible to use the widget's own state components as the source of truth,
/// in many cases widgets will be used to display dynamic data coming from deeper within the app,
/// using some kind of data-binding. This example shows how to maintain an external source of
/// truth for widget states.
#[derive(Resource)]
struct DemoWidgetStates {
slider_value: f32,
}
fn update_button_style(
mut buttons: Query<
(
@ -157,16 +189,45 @@ fn set_button_style(
}
}
/// Update the widget states based on the changing resource.
fn update_widget_values(
res: Res<DemoWidgetStates>,
mut sliders: Query<Entity, With<DemoSlider>>,
mut commands: Commands,
) {
if res.is_changed() {
for slider_ent in sliders.iter_mut() {
commands
.entity(slider_ent)
.insert(SliderValue(res.slider_value));
}
}
}
fn setup(mut commands: Commands, assets: Res<AssetServer>) {
// System to print a value when the button is clicked.
let on_click = commands.register_system(|| {
info!("Button clicked!");
});
// System to update a resource when the slider value changes. Note that we could have
// updated the slider value directly, but we want to demonstrate externalizing the state.
let on_change_value = commands.register_system(
|value: In<f32>, mut widget_states: ResMut<DemoWidgetStates>| {
widget_states.slider_value = *value;
},
);
// ui camera
commands.spawn(Camera2d);
commands.spawn(button(&assets, on_click));
commands.spawn(demo_root(&assets, on_click, on_change_value));
}
fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
fn demo_root(
asset_server: &AssetServer,
on_click: SystemId,
on_change_value: SystemId<In<f32>>,
) -> impl Bundle {
(
Node {
width: Val::Percent(100.0),
@ -175,57 +236,197 @@ fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
justify_content: JustifyContent::Center,
display: Display::Flex,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(10.0),
..default()
},
TabGroup::default(),
children![
(
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
DemoButton,
CoreButton {
on_click: Some(on_click),
},
Hovered::default(),
TabIndex(0),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Button"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 33.0,
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)]
),
Text::new("Press 'D' to toggle button disabled state"),
button(asset_server, on_click),
slider(0.0, 100.0, 50.0, Some(on_change_value)),
Text::new("Press 'D' to toggle widget disabled states"),
],
)
}
fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
(
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
DemoButton,
CoreButton {
on_click: Some(on_click),
},
Hovered::default(),
TabIndex(0),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Button"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 33.0,
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)],
)
}
/// Create a demo slider
fn slider(min: f32, max: f32, value: f32, on_change: Option<SystemId<In<f32>>>) -> impl Bundle {
(
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Stretch,
justify_items: JustifyItems::Center,
column_gap: Val::Px(4.0),
height: Val::Px(12.0),
width: Val::Percent(30.0),
..default()
},
Name::new("Slider"),
Hovered::default(),
DemoSlider,
CoreSlider {
on_change,
track_click: TrackClick::Snap,
},
SliderValue(value),
SliderRange::new(min, max),
TabIndex(0),
Children::spawn((
// Slider background rail
Spawn((
Node {
height: Val::Px(6.0),
..default()
},
BackgroundColor(SLIDER_TRACK), // Border color for the checkbox
BorderRadius::all(Val::Px(3.0)),
)),
// Invisible track to allow absolute placement of thumb entity. This is narrower than
// the actual slider, which allows us to position the thumb entity using simple
// percentages, without having to measure the actual width of the slider thumb.
Spawn((
Node {
display: Display::Flex,
position_type: PositionType::Absolute,
left: Val::Px(0.0),
// Track is short by 12px to accommodate the thumb.
right: Val::Px(12.0),
top: Val::Px(0.0),
bottom: Val::Px(0.0),
..default()
},
children![(
// Thumb
DemoSliderThumb,
CoreSliderThumb,
Node {
display: Display::Flex,
width: Val::Px(12.0),
height: Val::Px(12.0),
position_type: PositionType::Absolute,
left: Val::Percent(0.0), // This will be updated by the slider's value
..default()
},
BorderRadius::MAX,
BackgroundColor(SLIDER_THUMB),
)],
)),
)),
)
}
/// Update the visuals of the slider based on the slider state.
fn update_slider_style(
sliders: Query<
(
Entity,
&SliderValue,
&SliderRange,
&Hovered,
Has<InteractionDisabled>,
),
(
Or<(
Changed<SliderValue>,
Changed<SliderRange>,
Changed<Hovered>,
Added<InteractionDisabled>,
)>,
With<DemoSlider>,
),
>,
children: Query<&Children>,
mut thumbs: Query<(&mut Node, &mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
) {
for (slider_ent, value, range, hovered, disabled) in sliders.iter() {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_node, mut thumb_bg, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_node.left = Val::Percent(range.thumb_position(value.0) * 100.0);
thumb_bg.0 = thumb_color(disabled, hovered.0);
}
}
}
}
}
fn update_slider_style2(
sliders: Query<(Entity, &Hovered, Has<InteractionDisabled>), With<DemoSlider>>,
children: Query<&Children>,
mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
mut removed_disabled: RemovedComponents<InteractionDisabled>,
) {
removed_disabled.read().for_each(|entity| {
if let Ok((slider_ent, hovered, disabled)) = sliders.get(entity) {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_bg.0 = thumb_color(disabled, hovered.0);
}
}
}
}
});
}
fn thumb_color(disabled: bool, hovered: bool) -> Color {
match (disabled, hovered) {
(true, _) => GRAY.into(),
(false, true) => SLIDER_THUMB.lighter(0.3),
_ => SLIDER_THUMB,
}
}
fn toggle_disabled(
input: Res<ButtonInput<KeyCode>>,
mut interaction_query: Query<(Entity, Has<InteractionDisabled>), With<CoreButton>>,
mut interaction_query: Query<
(Entity, Has<InteractionDisabled>),
Or<(With<CoreButton>, With<CoreSlider>)>,
>,
mut commands: Commands,
) {
if input.just_pressed(KeyCode::KeyD) {
for (entity, disabled) in &mut interaction_query {
// disabled.0 = !disabled.0;
if disabled {
info!("Button enabled");
info!("Widgets enabled");
commands.entity(entity).remove::<InteractionDisabled>();
} else {
info!("Button disabled");
info!("Widgets disabled");
commands.entity(entity).insert(InteractionDisabled);
}
}

View File

@ -2,7 +2,9 @@
use bevy::{
color::palettes::basic::*,
core_widgets::{CoreButton, CoreWidgetsPlugin},
core_widgets::{
CoreButton, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue,
},
ecs::system::SystemId,
input_focus::{
tab_navigation::{TabGroup, TabIndex},
@ -19,25 +21,52 @@ fn main() {
.add_plugins((DefaultPlugins, CoreWidgetsPlugin, InputDispatchPlugin))
// Only run the app when there is user input. This will significantly reduce CPU/GPU use.
.insert_resource(WinitSettings::desktop_app())
.insert_resource(DemoWidgetStates { slider_value: 50.0 })
.add_systems(Startup, setup)
.add_observer(on_add_pressed)
.add_observer(on_remove_pressed)
.add_observer(on_add_disabled)
.add_observer(on_remove_disabled)
.add_observer(on_change_hover)
.add_systems(Update, toggle_disabled)
.add_observer(button_on_add_pressed)
.add_observer(button_on_remove_pressed)
.add_observer(button_on_add_disabled)
.add_observer(button_on_remove_disabled)
.add_observer(button_on_change_hover)
.add_observer(slider_on_add_disabled)
.add_observer(slider_on_remove_disabled)
.add_observer(slider_on_change_hover)
.add_observer(slider_on_change_value)
.add_observer(slider_on_change_range)
.add_systems(Update, (update_widget_values, toggle_disabled))
.run();
}
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05);
const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35);
/// Marker which identifies buttons with a particular style, in this case the "Demo style".
#[derive(Component)]
struct DemoButton;
fn on_add_pressed(
/// Marker which identifies sliders with a particular style.
#[derive(Component, Default)]
struct DemoSlider;
/// Marker which identifies the slider's thumb element.
#[derive(Component, Default)]
struct DemoSliderThumb;
/// A struct to hold the state of various widgets shown in the demo.
///
/// While it is possible to use the widget's own state components as the source of truth,
/// in many cases widgets will be used to display dynamic data coming from deeper within the app,
/// using some kind of data-binding. This example shows how to maintain an external source of
/// truth for widget states.
#[derive(Resource)]
struct DemoWidgetStates {
slider_value: f32,
}
fn button_on_add_pressed(
trigger: On<Add, Pressed>,
mut buttons: Query<
(
@ -66,7 +95,7 @@ fn on_add_pressed(
}
}
fn on_remove_pressed(
fn button_on_remove_pressed(
trigger: On<Remove, Pressed>,
mut buttons: Query<
(
@ -95,7 +124,7 @@ fn on_remove_pressed(
}
}
fn on_add_disabled(
fn button_on_add_disabled(
trigger: On<Add, InteractionDisabled>,
mut buttons: Query<
(
@ -124,7 +153,7 @@ fn on_add_disabled(
}
}
fn on_remove_disabled(
fn button_on_remove_disabled(
trigger: On<Remove, InteractionDisabled>,
mut buttons: Query<
(
@ -153,7 +182,7 @@ fn on_remove_disabled(
}
}
fn on_change_hover(
fn button_on_change_hover(
trigger: On<Insert, Hovered>,
mut buttons: Query<
(
@ -227,16 +256,140 @@ fn set_button_style(
}
}
fn slider_on_add_disabled(
trigger: On<Add, InteractionDisabled>,
sliders: Query<(Entity, &Hovered), With<DemoSlider>>,
children: Query<&Children>,
mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
) {
if let Ok((slider_ent, hovered)) = sliders.get(trigger.target().unwrap()) {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_bg.0 = thumb_color(true, hovered.0);
}
}
}
}
}
fn slider_on_remove_disabled(
trigger: On<Remove, InteractionDisabled>,
sliders: Query<(Entity, &Hovered), With<DemoSlider>>,
children: Query<&Children>,
mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
) {
if let Ok((slider_ent, hovered)) = sliders.get(trigger.target().unwrap()) {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_bg.0 = thumb_color(false, hovered.0);
}
}
}
}
}
fn slider_on_change_hover(
trigger: On<Insert, Hovered>,
sliders: Query<(Entity, &Hovered, Has<InteractionDisabled>), With<DemoSlider>>,
children: Query<&Children>,
mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
) {
if let Ok((slider_ent, hovered, disabled)) = sliders.get(trigger.target().unwrap()) {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_bg.0 = thumb_color(disabled, hovered.0);
}
}
}
}
}
fn slider_on_change_value(
trigger: On<Insert, SliderValue>,
sliders: Query<(Entity, &SliderValue, &SliderRange), With<DemoSlider>>,
children: Query<&Children>,
mut thumbs: Query<(&mut Node, Has<DemoSliderThumb>), Without<DemoSlider>>,
) {
if let Ok((slider_ent, value, range)) = sliders.get(trigger.target().unwrap()) {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_node, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_node.left = Val::Percent(range.thumb_position(value.0) * 100.0);
}
}
}
}
}
fn slider_on_change_range(
trigger: On<Insert, SliderRange>,
sliders: Query<(Entity, &SliderValue, &SliderRange), With<DemoSlider>>,
children: Query<&Children>,
mut thumbs: Query<(&mut Node, Has<DemoSliderThumb>), Without<DemoSlider>>,
) {
if let Ok((slider_ent, value, range)) = sliders.get(trigger.target().unwrap()) {
for child in children.iter_descendants(slider_ent) {
if let Ok((mut thumb_node, is_thumb)) = thumbs.get_mut(child) {
if is_thumb {
thumb_node.left = Val::Percent(range.thumb_position(value.0) * 100.0);
}
}
}
}
}
fn thumb_color(disabled: bool, hovered: bool) -> Color {
match (disabled, hovered) {
(true, _) => GRAY.into(),
(false, true) => SLIDER_THUMB.lighter(0.3),
_ => SLIDER_THUMB,
}
}
/// Update the widget states based on the changing resource.
fn update_widget_values(
res: Res<DemoWidgetStates>,
mut sliders: Query<Entity, With<DemoSlider>>,
mut commands: Commands,
) {
if res.is_changed() {
for slider_ent in sliders.iter_mut() {
commands
.entity(slider_ent)
.insert(SliderValue(res.slider_value));
}
}
}
fn setup(mut commands: Commands, assets: Res<AssetServer>) {
// System to print a value when the button is clicked.
let on_click = commands.register_system(|| {
info!("Button clicked!");
});
// System to update a resource when the slider value changes. Note that we could have
// updated the slider value directly, but we want to demonstrate externalizing the state.
let on_change_value = commands.register_system(
|value: In<f32>, mut widget_states: ResMut<DemoWidgetStates>| {
widget_states.slider_value = *value;
},
);
// ui camera
commands.spawn(Camera2d);
commands.spawn(button(&assets, on_click));
commands.spawn(demo_root(&assets, on_click, on_change_value));
}
fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
fn demo_root(
asset_server: &AssetServer,
on_click: SystemId,
on_change_value: SystemId<In<f32>>,
) -> impl Bundle {
(
Node {
width: Val::Percent(100.0),
@ -245,57 +398,133 @@ fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
justify_content: JustifyContent::Center,
display: Display::Flex,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(10.0),
..default()
},
TabGroup::default(),
children![
(
button(asset_server, on_click),
slider(0.0, 100.0, 50.0, Some(on_change_value)),
Text::new("Press 'D' to toggle widget disabled states"),
],
)
}
fn button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
(
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
DemoButton,
CoreButton {
on_click: Some(on_click),
},
Hovered::default(),
TabIndex(0),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Button"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 33.0,
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)],
)
}
/// Create a demo slider
fn slider(min: f32, max: f32, value: f32, on_change: Option<SystemId<In<f32>>>) -> impl Bundle {
(
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Stretch,
justify_items: JustifyItems::Center,
column_gap: Val::Px(4.0),
height: Val::Px(12.0),
width: Val::Percent(30.0),
..default()
},
Name::new("Slider"),
Hovered::default(),
DemoSlider,
CoreSlider {
on_change,
..default()
},
SliderValue(value),
SliderRange::new(min, max),
TabIndex(0),
Children::spawn((
// Slider background rail
Spawn((
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
height: Val::Px(6.0),
..default()
},
DemoButton,
CoreButton {
on_click: Some(on_click),
BackgroundColor(SLIDER_TRACK), // Border color for the checkbox
BorderRadius::all(Val::Px(3.0)),
)),
// Invisible track to allow absolute placement of thumb entity. This is narrower than
// the actual slider, which allows us to position the thumb entity using simple
// percentages, without having to measure the actual width of the slider thumb.
Spawn((
Node {
display: Display::Flex,
position_type: PositionType::Absolute,
left: Val::Px(0.0),
// Track is short by 12px to accommodate the thumb.
right: Val::Px(12.0),
top: Val::Px(0.0),
bottom: Val::Px(0.0),
..default()
},
Hovered::default(),
TabIndex(0),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Button"),
TextFont {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 33.0,
// Thumb
DemoSliderThumb,
CoreSliderThumb,
Node {
display: Display::Flex,
width: Val::Px(12.0),
height: Val::Px(12.0),
position_type: PositionType::Absolute,
left: Val::Percent(0.0), // This will be updated by the slider's value
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)]
),
Text::new("Press 'D' to toggle button disabled state"),
],
BorderRadius::MAX,
BackgroundColor(SLIDER_THUMB),
)],
)),
)),
)
}
fn toggle_disabled(
input: Res<ButtonInput<KeyCode>>,
mut interaction_query: Query<(Entity, Has<InteractionDisabled>), With<CoreButton>>,
mut interaction_query: Query<
(Entity, Has<InteractionDisabled>),
Or<(With<CoreButton>, With<CoreSlider>)>,
>,
mut commands: Commands,
) {
if input.just_pressed(KeyCode::KeyD) {
for (entity, disabled) in &mut interaction_query {
// disabled.0 = !disabled.0;
if disabled {
info!("Button enabled");
info!("Widgets enabled");
commands.entity(entity).remove::<InteractionDisabled>();
} else {
info!("Button disabled");
info!("Widgets disabled");
commands.entity(entity).insert(InteractionDisabled);
}
}

View File

@ -1,7 +1,7 @@
---
title: Headless Widgets
authors: ["@viridia"]
pull_requests: [19366]
pull_requests: [19366, 19584]
---
Bevy's `Button` and `Interaction` components have been around for a long time. Unfortunately
@ -33,7 +33,7 @@ The `bevy_core_widgets` crate provides implementations of unstyled widgets, such
sliders, checkboxes and radio buttons.
- `CoreButton` is a push button. It emits an activation event when clicked.
- (More to be added in subsequent PRs)
- `CoreSlider` is a standard slider, which lets you edit an `f32` value in a given range.
## Widget Interaction States
@ -63,13 +63,13 @@ is using Bevy observers. This approach is useful in cases where you want the wid
to bubble up the hierarchy.
However, in UI work it's often desirable to connect widget interactions in ways that cut across the
hierarchy. For these kinds of situations, the core widgets offer an an alternate approach: one-shot
hierarchy. For these kinds of situations, the core widgets offer a different approach: one-shot
systems. You can register a function as a one-shot system and get the resulting `SystemId`. This can
then be passed as a parameter to the widget when it is constructed, so when the button subsequently
gets clicked or the slider is dragged, the system gets run. Because it's an ECS system, it can
inject any additional parameters it needs to update the Bevy world in response to the interaction.
Most of the core widgets use "external state management" - something that is referred to in the
Most of the core widgets support "external state management" - something that is referred to in the
React.js world as "controlled" widgets. This means that for widgets that edit a parameter value
(such as checkboxes and sliders), the widget doesn't automatically update its own internal value,
but only sends a notification to the app telling it that the value needs to change. It's the
@ -83,6 +83,10 @@ interacting with that widget. Externalizing the state avoids the need for two-wa
instead allows simpler one-way data binding that aligns well with the traditional "Model / View /
Controller" (MVC) design pattern.
That being said, the choice of internal or external state management is up to you: if the widget
has an `on_change` callback that is not `None`, then the callback is used. If the callback
is `None`, however, the widget will update its own state. (This is similar to how React.js does it.)
There are two exceptions to this rule about external state management. First, widgets which don't
edit a value, but which merely trigger an event (such as buttons), don't fall under this rule.
Second, widgets which have complex states that are too large and heavyweight to fit within a