Feathers toggle switches. (#19928)
# Objective This is the Feathers toggle switch widget (without animation). Part of #19236 ### Showcase <img width="143" alt="toggles" src="https://github.com/user-attachments/assets/c04afc06-5a57-4bc6-8181-99efbd1bebef" />
This commit is contained in:
parent
0adbacd4c2
commit
870490808d
@ -10,6 +10,7 @@ keywords = ["bevy"]
|
||||
|
||||
[dependencies]
|
||||
# bevy
|
||||
bevy_a11y = { path = "../bevy_a11y", version = "0.17.0-dev" }
|
||||
bevy_app = { path = "../bevy_app", version = "0.17.0-dev" }
|
||||
bevy_asset = { path = "../bevy_asset", version = "0.17.0-dev" }
|
||||
bevy_color = { path = "../bevy_color", version = "0.17.0-dev" }
|
||||
|
@ -26,4 +26,10 @@ pub mod size {
|
||||
|
||||
/// Width and height of a radio button
|
||||
pub const RADIO_SIZE: Val = Val::Px(18.0);
|
||||
|
||||
/// Width of a toggle switch
|
||||
pub const TOGGLE_WIDTH: Val = Val::Px(32.0);
|
||||
|
||||
/// Height of a toggle switch
|
||||
pub const TOGGLE_HEIGHT: Val = Val::Px(18.0);
|
||||
}
|
||||
|
@ -37,6 +37,10 @@ pub struct CheckboxProps {
|
||||
pub on_change: Callback<In<bool>>,
|
||||
}
|
||||
|
||||
/// Marker for the checkbox frame (contains both checkbox and label)
|
||||
#[derive(Component, Default, Clone)]
|
||||
struct CheckboxFrame;
|
||||
|
||||
/// Marker for the checkbox outline
|
||||
#[derive(Component, Default, Clone)]
|
||||
struct CheckboxOutline;
|
||||
@ -68,6 +72,7 @@ pub fn checkbox<C: SpawnableList<ChildOf> + Send + Sync + 'static, B: Bundle>(
|
||||
CoreCheckbox {
|
||||
on_change: props.on_change,
|
||||
},
|
||||
CheckboxFrame,
|
||||
Hovered::default(),
|
||||
CursorIcon::System(bevy_window::SystemCursorIcon::Pointer),
|
||||
TabIndex(0),
|
||||
@ -124,7 +129,7 @@ fn update_checkbox_styles(
|
||||
&ThemeFontColor,
|
||||
),
|
||||
(
|
||||
With<CoreCheckbox>,
|
||||
With<CheckboxFrame>,
|
||||
Or<(Changed<Hovered>, Added<Checked>, Added<InteractionDisabled>)>,
|
||||
),
|
||||
>,
|
||||
@ -173,7 +178,7 @@ fn update_checkbox_styles_remove(
|
||||
&Hovered,
|
||||
&ThemeFontColor,
|
||||
),
|
||||
With<CoreCheckbox>,
|
||||
With<CheckboxFrame>,
|
||||
>,
|
||||
q_children: Query<&Children>,
|
||||
mut q_outline: Query<(&ThemeBackgroundColor, &ThemeBorderColor), With<CheckboxOutline>>,
|
||||
|
@ -5,17 +5,25 @@ mod button;
|
||||
mod checkbox;
|
||||
mod radio;
|
||||
mod slider;
|
||||
mod toggle_switch;
|
||||
|
||||
pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant};
|
||||
pub use checkbox::{checkbox, CheckboxPlugin, CheckboxProps};
|
||||
pub use radio::{radio, RadioPlugin};
|
||||
pub use slider::{slider, SliderPlugin, SliderProps};
|
||||
pub use toggle_switch::{toggle_switch, ToggleSwitchPlugin, ToggleSwitchProps};
|
||||
|
||||
/// Plugin which registers all `bevy_feathers` controls.
|
||||
pub struct ControlsPlugin;
|
||||
|
||||
impl Plugin for ControlsPlugin {
|
||||
fn build(&self, app: &mut bevy_app::App) {
|
||||
app.add_plugins((ButtonPlugin, CheckboxPlugin, RadioPlugin, SliderPlugin));
|
||||
app.add_plugins((
|
||||
ButtonPlugin,
|
||||
CheckboxPlugin,
|
||||
RadioPlugin,
|
||||
SliderPlugin,
|
||||
ToggleSwitchPlugin,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
249
crates/bevy_feathers/src/controls/toggle_switch.rs
Normal file
249
crates/bevy_feathers/src/controls/toggle_switch.rs
Normal file
@ -0,0 +1,249 @@
|
||||
use accesskit::Role;
|
||||
use bevy_a11y::AccessibilityNode;
|
||||
use bevy_app::{Plugin, PreUpdate};
|
||||
use bevy_core_widgets::{Callback, CoreCheckbox};
|
||||
use bevy_ecs::{
|
||||
bundle::Bundle,
|
||||
children,
|
||||
component::Component,
|
||||
entity::Entity,
|
||||
hierarchy::Children,
|
||||
lifecycle::RemovedComponents,
|
||||
query::{Added, Changed, Has, Or, With},
|
||||
schedule::IntoScheduleConfigs,
|
||||
spawn::SpawnRelated,
|
||||
system::{Commands, In, Query},
|
||||
world::Mut,
|
||||
};
|
||||
use bevy_input_focus::tab_navigation::TabIndex;
|
||||
use bevy_picking::{hover::Hovered, PickingSystems};
|
||||
use bevy_ui::{BorderRadius, Checked, InteractionDisabled, Node, PositionType, UiRect, Val};
|
||||
use bevy_winit::cursor::CursorIcon;
|
||||
|
||||
use crate::{
|
||||
constants::size,
|
||||
theme::{ThemeBackgroundColor, ThemeBorderColor},
|
||||
tokens,
|
||||
};
|
||||
|
||||
/// Parameters for the toggle switch template, passed to [`toggle_switch`] function.
|
||||
#[derive(Default)]
|
||||
pub struct ToggleSwitchProps {
|
||||
/// Change handler
|
||||
pub on_change: Callback<In<bool>>,
|
||||
}
|
||||
|
||||
/// Marker for the toggle switch outline
|
||||
#[derive(Component, Default, Clone)]
|
||||
struct ToggleSwitchOutline;
|
||||
|
||||
/// Marker for the toggle switch slide
|
||||
#[derive(Component, Default, Clone)]
|
||||
struct ToggleSwitchSlide;
|
||||
|
||||
/// Template function to spawn a toggle switch.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `props` - construction properties for the toggle switch.
|
||||
/// * `overrides` - a bundle of components that are merged in with the normal toggle switch components.
|
||||
pub fn toggle_switch<B: Bundle>(props: ToggleSwitchProps, overrides: B) -> impl Bundle {
|
||||
(
|
||||
Node {
|
||||
width: size::TOGGLE_WIDTH,
|
||||
height: size::TOGGLE_HEIGHT,
|
||||
border: UiRect::all(Val::Px(2.0)),
|
||||
..Default::default()
|
||||
},
|
||||
CoreCheckbox {
|
||||
on_change: props.on_change,
|
||||
},
|
||||
ToggleSwitchOutline,
|
||||
BorderRadius::all(Val::Px(5.0)),
|
||||
ThemeBackgroundColor(tokens::SWITCH_BG),
|
||||
ThemeBorderColor(tokens::SWITCH_BORDER),
|
||||
AccessibilityNode(accesskit::Node::new(Role::Switch)),
|
||||
Hovered::default(),
|
||||
CursorIcon::System(bevy_window::SystemCursorIcon::Pointer),
|
||||
TabIndex(0),
|
||||
overrides,
|
||||
children![(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.),
|
||||
top: Val::Px(0.),
|
||||
bottom: Val::Px(0.),
|
||||
width: Val::Percent(50.),
|
||||
..Default::default()
|
||||
},
|
||||
BorderRadius::all(Val::Px(3.0)),
|
||||
ToggleSwitchSlide,
|
||||
ThemeBackgroundColor(tokens::SWITCH_SLIDE),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn update_switch_styles(
|
||||
q_switches: Query<
|
||||
(
|
||||
Entity,
|
||||
Has<InteractionDisabled>,
|
||||
Has<Checked>,
|
||||
&Hovered,
|
||||
&ThemeBackgroundColor,
|
||||
&ThemeBorderColor,
|
||||
),
|
||||
(
|
||||
With<ToggleSwitchOutline>,
|
||||
Or<(Changed<Hovered>, Added<Checked>, Added<InteractionDisabled>)>,
|
||||
),
|
||||
>,
|
||||
q_children: Query<&Children>,
|
||||
mut q_slide: Query<(&mut Node, &ThemeBackgroundColor), With<ToggleSwitchSlide>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for (switch_ent, disabled, checked, hovered, outline_bg, outline_border) in q_switches.iter() {
|
||||
let Some(slide_ent) = q_children
|
||||
.iter_descendants(switch_ent)
|
||||
.find(|en| q_slide.contains(*en))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Safety: since we just checked the query, should always work.
|
||||
let (ref mut slide_style, slide_color) = q_slide.get_mut(slide_ent).unwrap();
|
||||
set_switch_colors(
|
||||
switch_ent,
|
||||
slide_ent,
|
||||
disabled,
|
||||
checked,
|
||||
hovered.0,
|
||||
outline_bg,
|
||||
outline_border,
|
||||
slide_style,
|
||||
slide_color,
|
||||
&mut commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_switch_styles_remove(
|
||||
q_switches: Query<
|
||||
(
|
||||
Entity,
|
||||
Has<InteractionDisabled>,
|
||||
Has<Checked>,
|
||||
&Hovered,
|
||||
&ThemeBackgroundColor,
|
||||
&ThemeBorderColor,
|
||||
),
|
||||
With<ToggleSwitchOutline>,
|
||||
>,
|
||||
q_children: Query<&Children>,
|
||||
mut q_slide: Query<(&mut Node, &ThemeBackgroundColor), With<ToggleSwitchSlide>>,
|
||||
mut removed_disabled: RemovedComponents<InteractionDisabled>,
|
||||
mut removed_checked: RemovedComponents<Checked>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
removed_disabled
|
||||
.read()
|
||||
.chain(removed_checked.read())
|
||||
.for_each(|ent| {
|
||||
if let Ok((switch_ent, disabled, checked, hovered, outline_bg, outline_border)) =
|
||||
q_switches.get(ent)
|
||||
{
|
||||
let Some(slide_ent) = q_children
|
||||
.iter_descendants(switch_ent)
|
||||
.find(|en| q_slide.contains(*en))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Safety: since we just checked the query, should always work.
|
||||
let (ref mut slide_style, slide_color) = q_slide.get_mut(slide_ent).unwrap();
|
||||
set_switch_colors(
|
||||
switch_ent,
|
||||
slide_ent,
|
||||
disabled,
|
||||
checked,
|
||||
hovered.0,
|
||||
outline_bg,
|
||||
outline_border,
|
||||
slide_style,
|
||||
slide_color,
|
||||
&mut commands,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_switch_colors(
|
||||
switch_ent: Entity,
|
||||
slide_ent: Entity,
|
||||
disabled: bool,
|
||||
checked: bool,
|
||||
hovered: bool,
|
||||
outline_bg: &ThemeBackgroundColor,
|
||||
outline_border: &ThemeBorderColor,
|
||||
slide_style: &mut Mut<Node>,
|
||||
slide_color: &ThemeBackgroundColor,
|
||||
commands: &mut Commands,
|
||||
) {
|
||||
let outline_border_token = match (disabled, hovered) {
|
||||
(true, _) => tokens::SWITCH_BORDER_DISABLED,
|
||||
(false, true) => tokens::SWITCH_BORDER_HOVER,
|
||||
_ => tokens::SWITCH_BORDER,
|
||||
};
|
||||
|
||||
let outline_bg_token = match (disabled, checked) {
|
||||
(true, true) => tokens::SWITCH_BG_CHECKED_DISABLED,
|
||||
(true, false) => tokens::SWITCH_BG_DISABLED,
|
||||
(false, true) => tokens::SWITCH_BG_CHECKED,
|
||||
(false, false) => tokens::SWITCH_BG,
|
||||
};
|
||||
|
||||
let slide_token = match disabled {
|
||||
true => tokens::SWITCH_SLIDE_DISABLED,
|
||||
false => tokens::SWITCH_SLIDE,
|
||||
};
|
||||
|
||||
let slide_pos = match checked {
|
||||
true => Val::Percent(50.),
|
||||
false => Val::Percent(0.),
|
||||
};
|
||||
|
||||
// Change outline background
|
||||
if outline_bg.0 != outline_bg_token {
|
||||
commands
|
||||
.entity(switch_ent)
|
||||
.insert(ThemeBackgroundColor(outline_bg_token));
|
||||
}
|
||||
|
||||
// Change outline border
|
||||
if outline_border.0 != outline_border_token {
|
||||
commands
|
||||
.entity(switch_ent)
|
||||
.insert(ThemeBorderColor(outline_border_token));
|
||||
}
|
||||
|
||||
// Change slide color
|
||||
if slide_color.0 != slide_token {
|
||||
commands
|
||||
.entity(slide_ent)
|
||||
.insert(ThemeBackgroundColor(slide_token));
|
||||
}
|
||||
|
||||
// Change slide position
|
||||
if slide_pos != slide_style.left {
|
||||
slide_style.left = slide_pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin which registers the systems for updating the toggle switch styles.
|
||||
pub struct ToggleSwitchPlugin;
|
||||
|
||||
impl Plugin for ToggleSwitchPlugin {
|
||||
fn build(&self, app: &mut bevy_app::App) {
|
||||
app.add_systems(
|
||||
PreUpdate,
|
||||
(update_switch_styles, update_switch_styles_remove).in_set(PickingSystems::Last),
|
||||
);
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ pub fn create_dark_theme() -> ThemeProps {
|
||||
ThemeProps {
|
||||
color: HashMap::from([
|
||||
(tokens::WINDOW_BG.into(), palette::GRAY_0),
|
||||
// Button
|
||||
(tokens::BUTTON_BG.into(), palette::GRAY_3),
|
||||
(
|
||||
tokens::BUTTON_BG_HOVER.into(),
|
||||
@ -40,6 +41,7 @@ pub fn create_dark_theme() -> ThemeProps {
|
||||
tokens::BUTTON_PRIMARY_TEXT_DISABLED.into(),
|
||||
palette::WHITE.with_alpha(0.5),
|
||||
),
|
||||
// Slider
|
||||
(tokens::SLIDER_BG.into(), palette::GRAY_1),
|
||||
(tokens::SLIDER_BAR.into(), palette::ACCENT),
|
||||
(tokens::SLIDER_BAR_DISABLED.into(), palette::GRAY_2),
|
||||
@ -48,6 +50,7 @@ pub fn create_dark_theme() -> ThemeProps {
|
||||
tokens::SLIDER_TEXT_DISABLED.into(),
|
||||
palette::WHITE.with_alpha(0.5),
|
||||
),
|
||||
// Checkbox
|
||||
(tokens::CHECKBOX_BG.into(), palette::GRAY_3),
|
||||
(tokens::CHECKBOX_BG_CHECKED.into(), palette::ACCENT),
|
||||
(
|
||||
@ -74,6 +77,7 @@ pub fn create_dark_theme() -> ThemeProps {
|
||||
tokens::CHECKBOX_TEXT_DISABLED.into(),
|
||||
palette::LIGHT_GRAY_1.with_alpha(0.5),
|
||||
),
|
||||
// Radio
|
||||
(tokens::RADIO_BORDER.into(), palette::GRAY_3),
|
||||
(
|
||||
tokens::RADIO_BORDER_HOVER.into(),
|
||||
@ -93,6 +97,31 @@ pub fn create_dark_theme() -> ThemeProps {
|
||||
tokens::RADIO_TEXT_DISABLED.into(),
|
||||
palette::LIGHT_GRAY_1.with_alpha(0.5),
|
||||
),
|
||||
// Toggle Switch
|
||||
(tokens::SWITCH_BG.into(), palette::GRAY_3),
|
||||
(tokens::SWITCH_BG_CHECKED.into(), palette::ACCENT),
|
||||
(
|
||||
tokens::SWITCH_BG_DISABLED.into(),
|
||||
palette::GRAY_1.with_alpha(0.5),
|
||||
),
|
||||
(
|
||||
tokens::SWITCH_BG_CHECKED_DISABLED.into(),
|
||||
palette::GRAY_3.with_alpha(0.5),
|
||||
),
|
||||
(tokens::SWITCH_BORDER.into(), palette::GRAY_3),
|
||||
(
|
||||
tokens::SWITCH_BORDER_HOVER.into(),
|
||||
palette::GRAY_3.lighter(0.1),
|
||||
),
|
||||
(
|
||||
tokens::SWITCH_BORDER_DISABLED.into(),
|
||||
palette::GRAY_3.with_alpha(0.5),
|
||||
),
|
||||
(tokens::SWITCH_SLIDE.into(), palette::LIGHT_GRAY_2),
|
||||
(
|
||||
tokens::SWITCH_SLIDE_DISABLED.into(),
|
||||
palette::LIGHT_GRAY_2.with_alpha(0.3),
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
@ -99,3 +99,24 @@ pub const RADIO_MARK_DISABLED: &str = "feathers.radio.mark.disabled";
|
||||
pub const RADIO_TEXT: &str = "feathers.radio.text";
|
||||
/// Radio label text (disabled)
|
||||
pub const RADIO_TEXT_DISABLED: &str = "feathers.radio.text.disabled";
|
||||
|
||||
// Toggle Switch
|
||||
|
||||
/// Switch background around the checkmark
|
||||
pub const SWITCH_BG: &str = "feathers.switch.bg";
|
||||
/// Switch border around the checkmark (disabled)
|
||||
pub const SWITCH_BG_DISABLED: &str = "feathers.switch.bg.disabled";
|
||||
/// Switch background around the checkmark
|
||||
pub const SWITCH_BG_CHECKED: &str = "feathers.switch.bg.checked";
|
||||
/// Switch border around the checkmark (disabled)
|
||||
pub const SWITCH_BG_CHECKED_DISABLED: &str = "feathers.switch.bg.checked.disabled";
|
||||
/// Switch border around the checkmark
|
||||
pub const SWITCH_BORDER: &str = "feathers.switch.border";
|
||||
/// Switch border around the checkmark (hovered)
|
||||
pub const SWITCH_BORDER_HOVER: &str = "feathers.switch.border.hover";
|
||||
/// Switch border around the checkmark (disabled)
|
||||
pub const SWITCH_BORDER_DISABLED: &str = "feathers.switch.border.disabled";
|
||||
/// Switch slide
|
||||
pub const SWITCH_SLIDE: &str = "feathers.switch.slide";
|
||||
/// Switch slide (disabled)
|
||||
pub const SWITCH_SLIDE_DISABLED: &str = "feathers.switch.slide.disabled";
|
||||
|
@ -4,7 +4,8 @@ use bevy::{
|
||||
core_widgets::{Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugin, SliderStep},
|
||||
feathers::{
|
||||
controls::{
|
||||
button, checkbox, radio, slider, ButtonProps, ButtonVariant, CheckboxProps, SliderProps,
|
||||
button, checkbox, radio, slider, toggle_switch, ButtonProps, ButtonVariant,
|
||||
CheckboxProps, SliderProps, ToggleSwitchProps,
|
||||
},
|
||||
dark_theme::create_dark_theme,
|
||||
rounded_corners::RoundedCorners,
|
||||
@ -222,6 +223,36 @@ fn demo_root(commands: &mut Commands) -> impl Bundle {
|
||||
),
|
||||
]
|
||||
),
|
||||
(
|
||||
Node {
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Start,
|
||||
column_gap: Val::Px(8.0),
|
||||
..default()
|
||||
},
|
||||
children![
|
||||
toggle_switch(
|
||||
ToggleSwitchProps {
|
||||
on_change: Callback::Ignore,
|
||||
},
|
||||
(),
|
||||
),
|
||||
toggle_switch(
|
||||
ToggleSwitchProps {
|
||||
on_change: Callback::Ignore,
|
||||
},
|
||||
InteractionDisabled,
|
||||
),
|
||||
toggle_switch(
|
||||
ToggleSwitchProps {
|
||||
on_change: Callback::Ignore,
|
||||
},
|
||||
(InteractionDisabled, Checked),
|
||||
),
|
||||
]
|
||||
),
|
||||
slider(
|
||||
SliderProps {
|
||||
max: 100.0,
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Bevy Feathers
|
||||
authors: ["@viridia", "@Atlas16A"]
|
||||
pull_requests: [19730, 19900]
|
||||
pull_requests: [19730, 19900, 19928]
|
||||
---
|
||||
|
||||
To make it easier for Bevy engine developers and third-party tool creators to make comfortable, visually cohesive tooling,
|
||||
|
Loading…
Reference in New Issue
Block a user