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:
Talin 2025-07-02 18:09:31 -07:00 committed by GitHub
parent 0adbacd4c2
commit 870490808d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 355 additions and 5 deletions

View File

@ -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" }

View File

@ -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);
}

View File

@ -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>>,

View File

@ -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,
));
}
}

View 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),
);
}
}

View File

@ -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),
),
]),
}
}

View File

@ -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";

View File

@ -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,

View File

@ -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,