Fix and Polish Feathers Toggle Switch (#23830)

# Objective

This is a continuation of https://github.com/bevyengine/bevy/pull/23820
for toggle switches to fix their clicking behavior and add more style
tokens. Again, adding the `ActivateOnPress` component will change its
state instantly on mouse-press rather than mouse-release.

Styling is also changed for visibility and disambiguation.

---

## Showcase

### Before

<img width="120" height="31" alt="Screenshot 2026-04-16 at 1 53 12 PM"
src="https://github.com/user-attachments/assets/22fd0374-76cb-40bb-9428-cbd7c69f2e50"
/>

### After

<img width="159" height="31" alt="Screenshot 2026-04-16 at 3 32 21 PM"
src="https://github.com/user-attachments/assets/ecb5bee6-0dbd-4598-8487-cdb333575f78"
/>

(Not pictured: new intermediate styling for mouse press)
This commit is contained in:
Jordan Halase
2026-04-16 21:37:18 -05:00
committed by GitHub
parent 9c27c26805
commit 6c867fa44a
4 changed files with 326 additions and 47 deletions
@@ -18,8 +18,10 @@ use bevy_input_focus::tab_navigation::TabIndex;
use bevy_picking::{hover::Hovered, PickingSystems};
use bevy_reflect::{prelude::ReflectDefault, Reflect};
use bevy_scene::prelude::*;
use bevy_ui::{BorderRadius, Checked, InteractionDisabled, Node, PositionType, UiRect, Val};
use bevy_ui_widgets::Checkbox;
use bevy_ui::{
BorderRadius, Checked, InteractionDisabled, Node, PositionType, Pressed, UiRect, Val,
};
use bevy_ui_widgets::{ActivateOnPress, Checkbox};
use crate::{
constants::size,
@@ -69,10 +71,12 @@ pub fn toggle_switch() -> impl Scene {
top: Val::Px(0.),
bottom: Val::Px(0.),
width: Val::Percent(50.),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(3.0)),
}
ToggleSwitchSlide
ThemeBackgroundColor(tokens::SWITCH_SLIDE)
ThemeBackgroundColor(tokens::SWITCH_SLIDE_BG)
ThemeBorderColor(tokens::SWITCH_SLIDE_BORDER)
)]
}
}
@@ -114,11 +118,13 @@ pub fn toggle_switch_bundle<B: Bundle>(overrides: B) -> impl Bundle {
top: Val::Px(0.),
bottom: Val::Px(0.),
width: Val::Percent(50.),
border: UiRect::all(Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(3.0)),
..Default::default()
},
ToggleSwitchSlide,
ThemeBackgroundColor(tokens::SWITCH_SLIDE),
ThemeBackgroundColor(tokens::SWITCH_SLIDE_BG),
ThemeBorderColor(tokens::SWITCH_SLIDE_BORDER)
)],
)
}
@@ -129,20 +135,40 @@ fn update_switch_styles(
Entity,
Has<InteractionDisabled>,
Has<Checked>,
Has<Pressed>,
Has<ActivateOnPress>,
&Hovered,
&ThemeBackgroundColor,
&ThemeBorderColor,
),
(
With<ToggleSwitchOutline>,
Or<(Changed<Hovered>, Added<Checked>, Added<InteractionDisabled>)>,
Or<(
Changed<Hovered>,
Added<Checked>,
Added<Pressed>,
Added<InteractionDisabled>,
)>,
),
>,
q_children: Query<&Children>,
mut q_slide: Query<(&mut Node, &ThemeBackgroundColor), With<ToggleSwitchSlide>>,
mut q_slide: Query<
(&mut Node, &ThemeBackgroundColor, &ThemeBorderColor),
With<ToggleSwitchSlide>,
>,
mut commands: Commands,
) {
for (switch_ent, disabled, checked, hovered, outline_bg, outline_border) in q_switches.iter() {
for (
switch_ent,
disabled,
checked,
pressed,
activate_on_press,
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))
@@ -150,17 +176,21 @@ fn update_switch_styles(
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();
let (ref mut slide_style, slide_bg_color, slide_border_color) =
q_slide.get_mut(slide_ent).unwrap();
set_switch_styles(
switch_ent,
slide_ent,
disabled,
checked,
pressed,
hovered.0,
activate_on_press,
outline_bg,
outline_border,
slide_style,
slide_color,
slide_bg_color,
slide_border_color,
&mut commands,
);
}
@@ -172,6 +202,8 @@ fn update_switch_styles_remove(
Entity,
Has<InteractionDisabled>,
Has<Checked>,
Has<Pressed>,
Has<ActivateOnPress>,
&Hovered,
&ThemeBackgroundColor,
&ThemeBorderColor,
@@ -179,17 +211,32 @@ fn update_switch_styles_remove(
With<ToggleSwitchOutline>,
>,
q_children: Query<&Children>,
mut q_slide: Query<(&mut Node, &ThemeBackgroundColor), With<ToggleSwitchSlide>>,
mut q_slide: Query<
(&mut Node, &ThemeBackgroundColor, &ThemeBorderColor),
With<ToggleSwitchSlide>,
>,
mut removed_disabled: RemovedComponents<InteractionDisabled>,
mut removed_checked: RemovedComponents<Checked>,
mut remove_pressed: RemovedComponents<Pressed>,
mut remove_activate_on_press: RemovedComponents<ActivateOnPress>,
mut commands: Commands,
) {
removed_disabled
.read()
.chain(removed_checked.read())
.chain(remove_pressed.read())
.chain(remove_activate_on_press.read())
.for_each(|ent| {
if let Ok((switch_ent, disabled, checked, hovered, outline_bg, outline_border)) =
q_switches.get(ent)
if let Ok((
switch_ent,
disabled,
checked,
pressed,
activate_on_press,
hovered,
outline_bg,
outline_border,
)) = q_switches.get(ent)
{
let Some(slide_ent) = q_children
.iter_descendants(switch_ent)
@@ -198,17 +245,21 @@ fn update_switch_styles_remove(
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();
let (ref mut slide_style, slide_bg_color, slide_border_color) =
q_slide.get_mut(slide_ent).unwrap();
set_switch_styles(
switch_ent,
slide_ent,
disabled,
checked,
pressed,
hovered.0,
activate_on_press,
outline_bg,
outline_border,
slide_style,
slide_color,
slide_bg_color,
slide_border_color,
&mut commands,
);
}
@@ -220,29 +271,102 @@ fn set_switch_styles(
slide_ent: Entity,
disabled: bool,
checked: bool,
pressed: bool,
hovered: bool,
activate_on_press: bool,
outline_bg: &ThemeBackgroundColor,
outline_border: &ThemeBorderColor,
slide_style: &mut Mut<Node>,
slide_color: &ThemeBackgroundColor,
slide_bg_color: &ThemeBackgroundColor,
slide_border_color: &ThemeBorderColor,
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_border_token = if checked {
if disabled {
tokens::SWITCH_BORDER_CHECKED_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_BORDER_CHECKED_PRESSED
} else if hovered {
tokens::SWITCH_BORDER_CHECKED_HOVER
} else {
tokens::SWITCH_BORDER_CHECKED
}
} else {
if disabled {
tokens::SWITCH_BORDER_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_BORDER_PRESSED
} else if hovered {
tokens::SWITCH_BORDER_HOVER
} else {
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 outline_bg_token = if checked {
if disabled {
tokens::SWITCH_BG_CHECKED_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_BG_CHECKED_PRESSED
} else if hovered {
tokens::SWITCH_BG_CHECKED_HOVER
} else {
tokens::SWITCH_BG_CHECKED
}
} else {
if disabled {
tokens::SWITCH_BG_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_BG_PRESSED
} else if hovered {
tokens::SWITCH_BG_HOVER
} else {
tokens::SWITCH_BG
}
};
let slide_token = match disabled {
true => tokens::SWITCH_SLIDE_DISABLED,
false => tokens::SWITCH_SLIDE,
let slide_border_token = if checked {
if disabled {
tokens::SWITCH_SLIDE_BORDER_CHECKED_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_SLIDE_BORDER_CHECKED_PRESSED
} else if hovered {
tokens::SWITCH_SLIDE_BORDER_CHECKED_HOVER
} else {
tokens::SWITCH_SLIDE_BORDER_CHECKED
}
} else {
if disabled {
tokens::SWITCH_SLIDE_BORDER_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_SLIDE_BORDER_PRESSED
} else if hovered {
tokens::SWITCH_SLIDE_BORDER_HOVER
} else {
tokens::SWITCH_SLIDE_BORDER
}
};
let slide_bg_token = if checked {
if disabled {
tokens::SWITCH_SLIDE_BG_CHECKED_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_SLIDE_BG_CHECKED_PRESSED
} else if hovered {
tokens::SWITCH_SLIDE_BG_CHECKED_HOVER
} else {
tokens::SWITCH_SLIDE_BG_CHECKED
}
} else {
if disabled {
tokens::SWITCH_SLIDE_BG_DISABLED
} else if pressed && !activate_on_press {
tokens::SWITCH_SLIDE_BG_PRESSED
} else if hovered {
tokens::SWITCH_SLIDE_BG_HOVER
} else {
tokens::SWITCH_SLIDE_BG
}
};
let slide_pos = match checked {
@@ -269,11 +393,18 @@ fn set_switch_styles(
.insert(ThemeBorderColor(outline_border_token));
}
// Change slide color
if slide_color.0 != slide_token {
// Change slide background color
if slide_bg_color.0 != slide_bg_token {
commands
.entity(slide_ent)
.insert(ThemeBackgroundColor(slide_token));
.insert(ThemeBackgroundColor(slide_bg_token));
}
// Change slide border color
if slide_border_color.0 != slide_border_token {
commands
.entity(slide_ent)
.insert(ThemeBorderColor(slide_border_token));
}
// Change slide position
+85 -5
View File
@@ -119,21 +119,101 @@ pub fn create_dark_theme() -> ThemeProps {
),
// Toggle Switch
(tokens::SWITCH_BG, palette::GRAY_3),
(tokens::SWITCH_BG_CHECKED, palette::ACCENT),
(tokens::SWITCH_BG_HOVER, palette::GRAY_3.lighter(0.05)),
(tokens::SWITCH_BG_PRESSED, palette::GRAY_3.lighter(0.1)),
(tokens::SWITCH_BG_DISABLED, palette::GRAY_1.with_alpha(0.5)),
(tokens::SWITCH_BG_CHECKED, palette::ACCENT),
(
tokens::SWITCH_BG_CHECKED_HOVER,
palette::ACCENT.lighter(0.05),
),
(
tokens::SWITCH_BG_CHECKED_PRESSED,
palette::ACCENT.lighter(0.1),
),
(
tokens::SWITCH_BG_CHECKED_DISABLED,
palette::GRAY_3.with_alpha(0.5),
palette::GRAY_1.with_alpha(0.5),
),
(tokens::SWITCH_BORDER, palette::GRAY_3),
(tokens::SWITCH_BORDER_HOVER, palette::GRAY_3.lighter(0.1)),
(tokens::SWITCH_BORDER_HOVER, palette::GRAY_3.lighter(0.05)),
(tokens::SWITCH_BORDER_PRESSED, palette::GRAY_3.lighter(0.1)),
(
tokens::SWITCH_BORDER_DISABLED,
palette::GRAY_3.with_alpha(0.5),
),
(tokens::SWITCH_SLIDE, palette::LIGHT_GRAY_2),
(tokens::SWITCH_BORDER_CHECKED, palette::ACCENT),
(
tokens::SWITCH_SLIDE_DISABLED,
tokens::SWITCH_BORDER_CHECKED_HOVER,
palette::ACCENT.lighter(0.05),
),
(
tokens::SWITCH_BORDER_CHECKED_PRESSED,
palette::ACCENT.lighter(0.1),
),
(
tokens::SWITCH_BORDER_CHECKED_DISABLED,
palette::GRAY_3.with_alpha(0.5),
),
(tokens::SWITCH_SLIDE_BG, palette::LIGHT_GRAY_1.lighter(0.1)),
(
tokens::SWITCH_SLIDE_BG_HOVER,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BG_PRESSED,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BG_DISABLED,
palette::GRAY_1.with_alpha(0.5),
),
(
tokens::SWITCH_SLIDE_BG_CHECKED,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BG_CHECKED_HOVER,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BG_CHECKED_PRESSED,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BG_CHECKED_DISABLED,
palette::LIGHT_GRAY_2.with_alpha(0.3),
),
(
tokens::SWITCH_SLIDE_BORDER,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BORDER_HOVER,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BORDER_PRESSED,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BORDER_DISABLED,
palette::GRAY_2.with_alpha(0.5),
),
(
tokens::SWITCH_SLIDE_BORDER_CHECKED,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BORDER_CHECKED_HOVER,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BORDER_CHECKED_PRESSED,
palette::LIGHT_GRAY_1.lighter(0.1),
),
(
tokens::SWITCH_SLIDE_BORDER_CHECKED_DISABLED,
palette::LIGHT_GRAY_2.with_alpha(0.3),
),
(tokens::COLOR_PLANE_BG, palette::GRAY_1),
+79 -12
View File
@@ -165,27 +165,94 @@ pub const RADIO_TEXT_DISABLED: ThemeToken = ThemeToken::new_static("feathers.rad
// Toggle Switch
/// Switch background around the checkmark
/// Switch background around the switch
pub const SWITCH_BG: ThemeToken = ThemeToken::new_static("feathers.switch.bg");
/// Switch border around the checkmark (disabled)
/// Switch background around the switch (hovered)
pub const SWITCH_BG_HOVER: ThemeToken = ThemeToken::new_static("feathers.switch.bg.hover");
/// Switch background around the switch (pressed)
pub const SWITCH_BG_PRESSED: ThemeToken = ThemeToken::new_static("feathers.switch.bg.pressed");
/// Switch background around the switch (disabled)
pub const SWITCH_BG_DISABLED: ThemeToken = ThemeToken::new_static("feathers.switch.bg.disabled");
/// Switch background around the checkmark
/// Switch background around the switch (checked)
pub const SWITCH_BG_CHECKED: ThemeToken = ThemeToken::new_static("feathers.switch.bg.checked");
/// Switch border around the checkmark (disabled)
/// Switch background around the switch (checked+hover)
pub const SWITCH_BG_CHECKED_HOVER: ThemeToken =
ThemeToken::new_static("feathers.switch.bg.checked.hover");
/// Switch background around the switch (checked+pressed)
pub const SWITCH_BG_CHECKED_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.bg.checked.pressed");
/// Switch background around the switch (checked+disabled)
pub const SWITCH_BG_CHECKED_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.bg.checked.disabled");
/// Switch border around the checkmark
/// Switch border around the switch
pub const SWITCH_BORDER: ThemeToken = ThemeToken::new_static("feathers.switch.border");
/// Switch border around the checkmark (hovered)
/// Switch border around the switch (hovered)
pub const SWITCH_BORDER_HOVER: ThemeToken = ThemeToken::new_static("feathers.switch.border.hover");
/// Switch border around the checkmark (disabled)
/// Switch border around the switch (pressed)
pub const SWITCH_BORDER_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.border.hover.pressed");
/// Switch border around the switch (disabled)
pub const SWITCH_BORDER_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.border.disabled");
/// Switch slide
pub const SWITCH_SLIDE: ThemeToken = ThemeToken::new_static("feathers.switch.slide");
/// Switch slide (disabled)
pub const SWITCH_SLIDE_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.disabled");
/// Switch border around the switch (checked)
pub const SWITCH_BORDER_CHECKED: ThemeToken =
ThemeToken::new_static("feathers.switch.border.checked");
/// Switch border around the switch (checked+hovered)
pub const SWITCH_BORDER_CHECKED_HOVER: ThemeToken =
ThemeToken::new_static("feathers.switch.border.checked.hover");
/// Switch border around the switch (checked+pressed)
pub const SWITCH_BORDER_CHECKED_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.border.checked.pressed");
/// Switch border around the switch (checked+disabled)
pub const SWITCH_BORDER_CHECKED_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.border.checked.disabled");
/// Switch slide background
pub const SWITCH_SLIDE_BG: ThemeToken = ThemeToken::new_static("feathers.switch.slide.bg");
/// Switch slide background (hovered)
pub const SWITCH_SLIDE_BG_HOVER: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.hover");
/// Switch slide background (pressed)
pub const SWITCH_SLIDE_BG_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.pressed");
/// Switch slide background (disabled)
pub const SWITCH_SLIDE_BG_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.disabled");
/// Switch slide background (checked)
pub const SWITCH_SLIDE_BG_CHECKED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.checked");
/// Switch slide background (checked+hovered)
pub const SWITCH_SLIDE_BG_CHECKED_HOVER: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.checked.hover");
/// Switch slide background (checked+pressed)
pub const SWITCH_SLIDE_BG_CHECKED_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.checked.pressed");
/// Switch slide background (checked+disabled)
pub const SWITCH_SLIDE_BG_CHECKED_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.bg.checked.disabled");
/// Switch slide border
pub const SWITCH_SLIDE_BORDER: ThemeToken = ThemeToken::new_static("feathers.switch.slide.border");
/// Switch slide border (hovered)
pub const SWITCH_SLIDE_BORDER_HOVER: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.hover");
/// Switch slide border (pressed)
pub const SWITCH_SLIDE_BORDER_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.pressed");
/// Switch slide border (disabled)
pub const SWITCH_SLIDE_BORDER_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.disabled");
/// Switch slide border (checked)
pub const SWITCH_SLIDE_BORDER_CHECKED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.checked");
/// Switch slide border (checked+hovered)
pub const SWITCH_SLIDE_BORDER_CHECKED_HOVER: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.checked.hover");
/// Switch slide border (checked+pressed)
pub const SWITCH_SLIDE_BORDER_CHECKED_PRESSED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.checked.pressed");
/// Switch slide border (checked+disabled)
pub const SWITCH_SLIDE_BORDER_CHECKED_DISABLED: ThemeToken =
ThemeToken::new_static("feathers.switch.slide.border.checked.disabled");
// Color Plane
+1
View File
@@ -404,6 +404,7 @@ fn demo_column_1() -> impl Scene {
}
Children [
(toggle_switch() on(checkbox_self_update)),
(toggle_switch() ActivateOnPress on(checkbox_self_update)),
(toggle_switch() InteractionDisabled on(checkbox_self_update)),
(toggle_switch() InteractionDisabled Checked on(checkbox_self_update)),
(disclosure_toggle() on(checkbox_self_update)),