docs/bevy/examples/ui/widgets/standard_widgets_observers.rs 16.9 K · 482 lines · raw · history

1 //! This experimental example illustrates how to create widgets using the `bevy_ui_widgets` widget set.
2 //!
3 //! The patterns shown here are likely to change substantially as the `bevy_ui_widgets` crate
4 //! matures, so please exercise caution if you are using this as a reference for your own code,
5 //! and note that there are still "user experience" issues with this API.
6
7 use bevy::{
8 color::palettes::basic::*,
9 ecs::{event::EventPattern, lifecycle::RemoveEvent},
10 input_focus::tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
11 picking::hover::Hovered,
12 prelude::*,
13 reflect::Is,
14 ui::{Checked, InteractionDisabled, Pressed},
15 ui_widgets::{
16 checkbox_self_update, observe, Activate, Button, Checkbox, Slider, SliderRange,
17 SliderThumb, SliderValue, ValueChange,
18 },
19 };
20
21 fn main() {
22 App::new()
23 .add_plugins((DefaultPlugins, TabNavigationPlugin))
24 .insert_resource(DemoWidgetStates { slider_value: 50.0 })
25 .add_systems(Startup, setup)
26 .add_observer(button_on_interaction::<Add<Pressed>>)
27 .add_observer(button_on_interaction::<Remove<Pressed>>)
28 .add_observer(button_on_interaction::<Add<InteractionDisabled>>)
29 .add_observer(button_on_interaction::<Remove<InteractionDisabled>>)
30 .add_observer(button_on_interaction::<Insert<Hovered>>)
31 .add_observer(slider_on_interaction::<Add<InteractionDisabled>>)
32 .add_observer(slider_on_interaction::<Remove<InteractionDisabled>>)
33 .add_observer(slider_on_interaction::<Insert<Hovered>>)
34 .add_observer(slider_on_change_value::<SliderValue>)
35 .add_observer(slider_on_change_value::<SliderRange>)
36 .add_observer(checkbox_on_interaction::<Add<InteractionDisabled>>)
37 .add_observer(checkbox_on_interaction::<Remove<InteractionDisabled>>)
38 .add_observer(checkbox_on_interaction::<Insert<Hovered>>)
39 .add_observer(checkbox_on_interaction::<Add<Checked>>)
40 .add_observer(checkbox_on_interaction::<Remove<Checked>>)
41 .add_systems(Update, (update_widget_values, toggle_disabled))
42 .run();
43 }
44
45 const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
46 const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
47 const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
48 const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05);
49 const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35);
50 const CHECKBOX_OUTLINE: Color = Color::srgb(0.45, 0.45, 0.45);
51 const CHECKBOX_CHECK: Color = Color::srgb(0.35, 0.75, 0.35);
52
53 /// Marker which identifies buttons with a particular style, in this case the "Demo style".
54 #[derive(Component)]
55 struct DemoButton;
56
57 /// Marker which identifies sliders with a particular style.
58 #[derive(Component, Default)]
59 struct DemoSlider;
60
61 /// Marker which identifies the slider's thumb element.
62 #[derive(Component, Default)]
63 struct DemoSliderThumb;
64
65 /// Marker which identifies checkboxes with a particular style.
66 #[derive(Component, Default)]
67 struct DemoCheckbox;
68
69 /// A struct to hold the state of various widgets shown in the demo.
70 ///
71 /// While it is possible to use the widget's own state components as the source of truth,
72 /// in many cases widgets will be used to display dynamic data coming from deeper within the app,
73 /// using some kind of data-binding. This example shows how to maintain an external source of
74 /// truth for widget states.
75 #[derive(Resource)]
76 struct DemoWidgetStates {
77 slider_value: f32,
78 }
79
80 fn setup(mut commands: Commands, assets: Res<AssetServer>) {
81 // ui camera
82 commands.spawn(Camera2d);
83 commands.spawn(demo_root(&assets));
84 }
85
86 fn demo_root(asset_server: &AssetServer) -> impl Bundle {
87 (
88 Node {
89 width: percent(100),
90 height: percent(100),
91 align_items: AlignItems::Center,
92 justify_content: JustifyContent::Center,
93 display: Display::Flex,
94 flex_direction: FlexDirection::Column,
95 row_gap: px(10),
96 ..default()
97 },
98 TabGroup::default(),
99 children![
100 (
101 button(asset_server),
102 observe(|_activate: On<Activate>| {
103 info!("Button clicked!");
104 }),
105 ),
106 (
107 slider(0.0, 100.0, 50.0),
108 observe(
109 |value_change: On<ValueChange<f32>>,
110 mut widget_states: ResMut<DemoWidgetStates>| {
111 widget_states.slider_value = value_change.value;
112 },
113 )
114 ),
115 (
116 checkbox(asset_server, "Checkbox"),
117 observe(checkbox_self_update),
118 ),
119 Text::new("Press 'D' to toggle widget disabled states"),
120 ],
121 )
122 }
123
124 fn button(asset_server: &AssetServer) -> impl Bundle {
125 (
126 Node {
127 width: px(150),
128 height: px(65),
129 border: UiRect::all(px(5)),
130 justify_content: JustifyContent::Center,
131 align_items: AlignItems::Center,
132 border_radius: BorderRadius::MAX,
133 ..default()
134 },
135 DemoButton,
136 Button,
137 Hovered::default(),
138 TabIndex(0),
139 BorderColor::all(Color::BLACK),
140 BackgroundColor(NORMAL_BUTTON),
141 children![(
142 Text::new("Button"),
143 TextFont {
144 font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
145 font_size: FontSize::Px(33.0),
146 ..default()
147 },
148 TextColor(Color::srgb(0.9, 0.9, 0.9)),
149 TextShadow::default(),
150 )],
151 )
152 }
153
154 fn button_on_interaction<E: EventPattern<Event: EntityEvent>>(
155 event: On<E>,
156 mut buttons: Query<
157 (
158 &Hovered,
159 Has<InteractionDisabled>,
160 Has<Pressed>,
161 &mut BackgroundColor,
162 &mut BorderColor,
163 &Children,
164 ),
165 With<DemoButton>,
166 >,
167 mut text_query: Query<&mut Text>,
168 ) {
169 if let Ok((hovered, disabled, pressed, mut color, mut border_color, children)) =
170 buttons.get_mut(event.event_target())
171 {
172 if children.is_empty() {
173 return;
174 }
175 let Ok(mut text) = text_query.get_mut(children[0]) else {
176 return;
177 };
178 let hovered = hovered.get();
179 // These "removal event checks" exist because the `Remove` event is triggered _before_ the component is actually
180 // removed, meaning it still shows up in the query. We're investigating the best way to improve this scenario.
181 let pressed = pressed && !(E::Event::is::<RemoveEvent>() && E::Components::is::<Pressed>());
182 let disabled = disabled
183 && !(E::Event::is::<RemoveEvent>() && E::Components::is::<InteractionDisabled>());
184 match (disabled, hovered, pressed) {
185 // Disabled button
186 (true, _, _) => {
187 **text = "Disabled".to_string();
188 *color = NORMAL_BUTTON.into();
189 border_color.set_all(GRAY);
190 }
191
192 // Pressed and hovered button
193 (false, true, true) => {
194 **text = "Press".to_string();
195 *color = PRESSED_BUTTON.into();
196 border_color.set_all(RED);
197 }
198
199 // Hovered, unpressed button
200 (false, true, false) => {
201 **text = "Hover".to_string();
202 *color = HOVERED_BUTTON.into();
203 border_color.set_all(WHITE);
204 }
205
206 // Unhovered button (either pressed or not).
207 (false, false, _) => {
208 **text = "Button".to_string();
209 *color = NORMAL_BUTTON.into();
210 border_color.set_all(BLACK);
211 }
212 }
213 }
214 }
215
216 /// Create a demo slider
217 fn slider(min: f32, max: f32, value: f32) -> impl Bundle {
218 (
219 Node {
220 display: Display::Flex,
221 flex_direction: FlexDirection::Column,
222 justify_content: JustifyContent::Center,
223 align_items: AlignItems::Stretch,
224 justify_items: JustifyItems::Center,
225 column_gap: px(4),
226 height: px(12),
227 width: percent(30),
228 ..default()
229 },
230 Name::new("Slider"),
231 Hovered::default(),
232 DemoSlider,
233 Slider::default(),
234 SliderValue(value),
235 SliderRange::new(min, max),
236 TabIndex(0),
237 Children::spawn((
238 // Slider background rail
239 Spawn((
240 Node {
241 height: px(6),
242 border_radius: BorderRadius::all(px(3)),
243 ..default()
244 },
245 BackgroundColor(SLIDER_TRACK), // Border color for the checkbox
246 )),
247 // Invisible track to allow absolute placement of thumb entity. This is narrower than
248 // the actual slider, which allows us to position the thumb entity using simple
249 // percentages, without having to measure the actual width of the slider thumb.
250 Spawn((
251 Node {
252 display: Display::Flex,
253 position_type: PositionType::Absolute,
254 left: px(0),
255 // Track is short by 12px to accommodate the thumb.
256 right: px(12),
257 top: px(0),
258 bottom: px(0),
259 ..default()
260 },
261 children![(
262 // Thumb
263 DemoSliderThumb,
264 SliderThumb,
265 Node {
266 display: Display::Flex,
267 width: px(12),
268 height: px(12),
269 position_type: PositionType::Absolute,
270 left: percent(0), // This will be updated by the slider's value
271 border_radius: BorderRadius::MAX,
272 ..default()
273 },
274 BackgroundColor(SLIDER_THUMB),
275 )],
276 )),
277 )),
278 )
279 }
280
281 fn slider_on_interaction<E: EventPattern<Event: EntityEvent>>(
282 event: On<E>,
283 sliders: Query<(Entity, &Hovered, Has<InteractionDisabled>), With<DemoSlider>>,
284 children: Query<&Children>,
285 mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
286 ) {
287 if let Ok((slider_ent, hovered, disabled)) = sliders.get(event.event_target()) {
288 // These "removal event checks" exist because the `Remove` event is triggered _before_ the component is actually
289 // removed, meaning it still shows up in the query. We're investigating the best way to improve this scenario.
290 let disabled = disabled
291 && !(E::Event::is::<RemoveEvent>() && E::Components::is::<InteractionDisabled>());
292 for child in children.iter_descendants(slider_ent) {
293 if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child)
294 && is_thumb
295 {
296 thumb_bg.0 = thumb_color(disabled, hovered.0);
297 }
298 }
299 }
300 }
301
302 fn slider_on_change_value<C: Component>(
303 insert: On<Insert<C>>,
304 sliders: Query<(Entity, &SliderValue, &SliderRange), With<DemoSlider>>,
305 children: Query<&Children>,
306 mut thumbs: Query<(&mut Node, Has<DemoSliderThumb>), Without<DemoSlider>>,
307 ) {
308 if let Ok((slider_ent, value, range)) = sliders.get(insert.entity) {
309 for child in children.iter_descendants(slider_ent) {
310 if let Ok((mut thumb_node, is_thumb)) = thumbs.get_mut(child)
311 && is_thumb
312 {
313 thumb_node.left = percent(range.thumb_position(value.0) * 100.0);
314 }
315 }
316 }
317 }
318
319 fn thumb_color(disabled: bool, hovered: bool) -> Color {
320 match (disabled, hovered) {
321 (true, _) => GRAY.into(),
322
323 (false, true) => SLIDER_THUMB.lighter(0.3),
324
325 _ => SLIDER_THUMB,
326 }
327 }
328
329 /// Create a demo checkbox
330 fn checkbox(asset_server: &AssetServer, caption: &str) -> impl Bundle {
331 (
332 Node {
333 display: Display::Flex,
334 flex_direction: FlexDirection::Row,
335 justify_content: JustifyContent::FlexStart,
336 align_items: AlignItems::Center,
337 align_content: AlignContent::Center,
338 column_gap: px(4),
339 ..default()
340 },
341 Name::new("Checkbox"),
342 Hovered::default(),
343 DemoCheckbox,
344 Checkbox,
345 TabIndex(0),
346 Children::spawn((
347 Spawn((
348 // Checkbox outer
349 Node {
350 display: Display::Flex,
351 width: px(16),
352 height: px(16),
353 border: UiRect::all(px(2)),
354 border_radius: BorderRadius::all(px(3)),
355 ..default()
356 },
357 BorderColor::all(CHECKBOX_OUTLINE), // Border color for the checkbox
358 children![
359 // Checkbox inner
360 (
361 Node {
362 display: Display::Flex,
363 width: px(8),
364 height: px(8),
365 position_type: PositionType::Absolute,
366 left: px(2),
367 top: px(2),
368 ..default()
369 },
370 BackgroundColor(Srgba::NONE.into()),
371 ),
372 ],
373 )),
374 Spawn((
375 Text::new(caption),
376 TextFont {
377 font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
378 font_size: FontSize::Px(20.0),
379 ..default()
380 },
381 )),
382 )),
383 )
384 }
385
386 fn checkbox_on_interaction<E: EventPattern<Event: EntityEvent>>(
387 event: On<E>,
388 checkboxes: Query<
389 (&Hovered, Has<InteractionDisabled>, Has<Checked>, &Children),
390 With<DemoCheckbox>,
391 >,
392 mut borders: Query<(&mut BorderColor, &mut Children), Without<DemoCheckbox>>,
393 mut marks: Query<&mut BackgroundColor, (Without<DemoCheckbox>, Without<Children>)>,
394 ) {
395 if let Ok((hovered, disabled, checked, children)) = checkboxes.get(event.event_target()) {
396 let hovered = hovered.get();
397 // These "removal event checks" exist because the `Remove` event is triggered _before_ the component is actually
398 // removed, meaning it still shows up in the query. We're investigating the best way to improve this scenario.
399 let checked = checked && !(E::Event::is::<RemoveEvent>() && E::Components::is::<Checked>());
400 let disabled = disabled
401 && !(E::Event::is::<RemoveEvent>() && E::Components::is::<InteractionDisabled>());
402
403 let Some(border_id) = children.first() else {
404 return;
405 };
406
407 let Ok((mut border_color, border_children)) = borders.get_mut(*border_id) else {
408 return;
409 };
410
411 let Some(mark_id) = border_children.first() else {
412 warn!("Checkbox does not have a mark entity.");
413 return;
414 };
415
416 let Ok(mut mark_bg) = marks.get_mut(*mark_id) else {
417 warn!("Checkbox mark entity lacking a background color.");
418 return;
419 };
420
421 let color: Color = if disabled {
422 // If the checkbox is disabled, use a lighter color
423 CHECKBOX_OUTLINE.with_alpha(0.2)
424 } else if hovered {
425 // If hovering, use a lighter color
426 CHECKBOX_OUTLINE.lighter(0.2)
427 } else {
428 // Default color for the checkbox
429 CHECKBOX_OUTLINE
430 };
431
432 // Update the background color of the check mark
433 border_color.set_all(color);
434
435 let mark_color: Color = match (disabled, checked) {
436 (true, true) => CHECKBOX_CHECK.with_alpha(0.5),
437 (false, true) => CHECKBOX_CHECK,
438 (_, false) => Srgba::NONE.into(),
439 };
440
441 if mark_bg.0 != mark_color {
442 // Update the color of the check mark
443 mark_bg.0 = mark_color;
444 }
445 }
446 }
447
448 /// Update the widget states based on the changing resource.
449 fn update_widget_values(
450 res: Res<DemoWidgetStates>,
451 mut sliders: Query<Entity, With<DemoSlider>>,
452 mut commands: Commands,
453 ) {
454 if res.is_changed() {
455 for slider_ent in sliders.iter_mut() {
456 commands
457 .entity(slider_ent)
458 .insert(SliderValue(res.slider_value));
459 }
460 }
461 }
462
463 fn toggle_disabled(
464 input: Res<ButtonInput<KeyCode>>,
465 mut interaction_query: Query<
466 (Entity, Has<InteractionDisabled>),
467 Or<(With<Button>, With<Slider>, With<Checkbox>)>,
468 >,
469 mut commands: Commands,
470 ) {
471 if input.just_pressed(KeyCode::KeyD) {
472 for (entity, disabled) in &mut interaction_query {
473 if disabled {
474 info!("Widget enabled");
475 commands.entity(entity).remove::<InteractionDisabled>();
476 } else {
477 info!("Widget disabled");
478 commands.entity(entity).insert(InteractionDisabled);
479 }
480 }
481 }
482 }