Skip to main content

draggable_slider/
draggable_slider.rs

1//! Demonstrates why pointer capture matters during drag operations.
2//!
3//! A slider sits above a row of buttons. Without [`PointerCaptureMap`], dragging the slider thumb
4//! over the buttons causes them to show a hover highlight — misleading the user into thinking the
5//! button is about to be activated. With capture enabled the slider owns the pointer for the
6//! duration of the drag, so no other widget receives hover state.
7//!
8//! Toggle capture on/off with the button at the bottom to see the difference.
9//!
10//! Additional UI elements showcased:
11//! - A **fill bar** that grows with the slider value.
12//! - **Min / max labels** flanking the track.
13//! - A **Reset button** that snaps the value back to the default.
14
15use bevy::prelude::*;
16
17const TRACK_WIDTH: f32 = 500.0;
18const TRACK_HEIGHT: f32 = 14.0;
19const THUMB_SIZE: f32 = 28.0;
20const THUMB_HALF: f32 = THUMB_SIZE / 2.0;
21const FILL_HEIGHT: f32 = 8.0;
22
23const SLIDER_DEFAULT_VALUE: f32 = 0.5;
24
25const COLOR_TRACK: Color = Color::srgb(0.2, 0.2, 0.2);
26const COLOR_FILL: Color = Color::srgb(0.35, 0.65, 0.35);
27const COLOR_FILL_DRAGGING: Color = Color::srgb(0.45, 0.85, 0.45);
28const COLOR_THUMB_IDLE: Color = Color::srgb(0.85, 0.55, 0.1);
29const COLOR_THUMB_HOVER: Color = Color::srgb(1.0, 0.75, 0.2);
30const COLOR_DECOY_IDLE: Color = Color::srgb(0.2, 0.45, 0.8);
31const COLOR_DECOY_HOVER: Color = Color::srgb(0.9, 0.2, 0.2);
32const COLOR_TOGGLE_ON: Color = Color::srgb(0.15, 0.65, 0.3);
33const COLOR_TOGGLE_OFF: Color = Color::srgb(0.55, 0.15, 0.15);
34const COLOR_RESET_IDLE: Color = Color::srgb(0.3, 0.3, 0.55);
35const COLOR_RESET_HOVER: Color = Color::srgb(0.5, 0.5, 0.75);
36
37/// The normalized (0 – 1) position of the slider thumb.
38#[derive(Resource)]
39struct SliderValue(f32);
40
41impl Default for SliderValue {
42    fn default() -> Self {
43        Self(SLIDER_DEFAULT_VALUE)
44    }
45}
46
47/// Whether [`PointerCaptureMap`] is used while dragging the thumb.
48#[derive(Resource)]
49struct CaptureEnabled(bool);
50
51impl Default for CaptureEnabled {
52    fn default() -> Self {
53        Self(true)
54    }
55}
56
57#[derive(Component)]
58struct SliderThumb;
59
60#[derive(Component)]
61struct SliderFill;
62
63#[derive(Component)]
64struct SliderLabel;
65
66#[derive(Component)]
67struct ToggleLabel;
68
69#[derive(Component)]
70struct ToggleButton;
71
72#[derive(Component)]
73struct ResetButton;
74
75#[derive(Component)]
76struct DecoyButton;
77
78/// X offset of the left edge of the thumb node.
79fn thumb_left(v: f32) -> f32 {
80    v.clamp(0.0, 1.0) * (TRACK_WIDTH - THUMB_SIZE)
81}
82
83/// Width of the fill bar (reaches to the center of the thumb).
84fn fill_width(v: f32) -> f32 {
85    v.clamp(0.0, 1.0) * (TRACK_WIDTH - THUMB_SIZE) + THUMB_HALF
86}
87
88fn main() {
89    App::new()
90        .add_plugins(DefaultPlugins)
91        .init_resource::<SliderValue>()
92        .init_resource::<CaptureEnabled>()
93        .add_systems(Startup, setup)
94        .add_systems(Update, (sync_thumb_and_label, sync_fill))
95        .run();
96}
97
98fn setup(mut commands: Commands) {
99    commands.spawn(Camera2d);
100
101    // Root column – centered, full-screen, non-pickable container.
102    commands
103        .spawn((
104            Node {
105                width: Val::Percent(100.0),
106                height: Val::Percent(100.0),
107                flex_direction: FlexDirection::Column,
108                align_items: AlignItems::Center,
109                justify_content: JustifyContent::Center,
110                row_gap: Val::Px(28.0),
111                ..default()
112            },
113            Pickable::IGNORE,
114        ))
115        .with_children(|root| {
116            root.spawn((
117                Text::new(
118                    "Drag the slider over the blue buttons.\n\
119                     With capture ON they stay blue. With capture OFF they incorrectly turn red.",
120                ),
121                TextFont::from_font_size(15.0),
122                TextColor(Color::srgb(0.75, 0.75, 0.75)),
123            ));
124
125            root.spawn((
126                Node {
127                    flex_direction: FlexDirection::Row,
128                    align_items: AlignItems::Center,
129                    column_gap: Val::Px(10.0),
130                    ..default()
131                },
132                Pickable::IGNORE,
133            ))
134            .with_children(|row| {
135                row.spawn((
136                    Text::new("0.0"),
137                    TextFont::from_font_size(13.0),
138                    TextColor(Color::srgb(0.55, 0.55, 0.55)),
139                ));
140
141                row.spawn((
142                    Node {
143                        width: Val::Px(TRACK_WIDTH),
144                        height: Val::Px(TRACK_HEIGHT),
145                        position_type: PositionType::Relative,
146                        align_items: AlignItems::Center,
147                        ..default()
148                    },
149                    BackgroundColor(COLOR_TRACK),
150                    Pickable::IGNORE,
151                ))
152                .with_children(|track| {
153                    track.spawn((
154                        SliderFill,
155                        Node {
156                            width: Val::Px(fill_width(SLIDER_DEFAULT_VALUE)),
157                            height: Val::Px(FILL_HEIGHT),
158                            position_type: PositionType::Absolute,
159                            left: Val::Px(0.0),
160                            ..default()
161                        },
162                        BackgroundColor(COLOR_FILL),
163                        Pickable::IGNORE,
164                    ));
165
166                    track
167                        .spawn((
168                            SliderThumb,
169                            Node {
170                                width: Val::Px(THUMB_SIZE),
171                                height: Val::Px(THUMB_SIZE),
172                                position_type: PositionType::Absolute,
173                                left: Val::Px(thumb_left(SLIDER_DEFAULT_VALUE)),
174                                border_radius: BorderRadius::all(Val::Px(4.0)),
175                                ..default()
176                            },
177                            BackgroundColor(COLOR_THUMB_IDLE),
178                        ))
179                        .observe(on_drag_start)
180                        .observe(on_drag)
181                        .observe(on_drag_end)
182                        .observe(
183                            |_: On<PointerOver>,
184                             mut q: Single<&mut BackgroundColor, With<SliderThumb>>| {
185                                q.0 = COLOR_THUMB_HOVER;
186                            },
187                        )
188                        .observe(
189                            |_: On<PointerOut>,
190                             mut q: Single<&mut BackgroundColor, With<SliderThumb>>| {
191                                q.0 = COLOR_THUMB_IDLE;
192                            },
193                        );
194                });
195
196                row.spawn((
197                    Text::new("1.0"),
198                    TextFont::from_font_size(13.0),
199                    TextColor(Color::srgb(0.55, 0.55, 0.55)),
200                ));
201            });
202
203            root.spawn((
204                SliderLabel,
205                Text::new(format!("Value: {:.2}", SLIDER_DEFAULT_VALUE)),
206                TextFont::from_font_size(18.0),
207            ));
208
209            root.spawn((
210                Node {
211                    flex_direction: FlexDirection::Row,
212                    column_gap: Val::Px(16.0),
213                    ..default()
214                },
215                Pickable::IGNORE,
216            ))
217            .with_children(|row| {
218                for label in ["Button A", "Button B", "Button C", "Button D", "Button E"] {
219                    spawn_decoy_button(row, label);
220                }
221            });
222
223            root.spawn((
224                Node {
225                    flex_direction: FlexDirection::Row,
226                    column_gap: Val::Px(20.0),
227                    align_items: AlignItems::Center,
228                    ..default()
229                },
230                Pickable::IGNORE,
231            ))
232            .with_children(|row| {
233                row.spawn((
234                    ToggleButton,
235                    Node {
236                        padding: UiRect::axes(Val::Px(20.0), Val::Px(10.0)),
237                        border_radius: BorderRadius::all(Val::Px(6.0)),
238                        ..default()
239                    },
240                    BackgroundColor(COLOR_TOGGLE_ON),
241                ))
242                .with_children(|btn| {
243                    btn.spawn((
244                        ToggleLabel,
245                        Text::new("Capture: ON  (click to toggle)"),
246                        TextFont::from_font_size(16.0),
247                        TextColor(Color::WHITE),
248                        Pickable::IGNORE,
249                    ));
250                })
251                .observe(on_toggle_click);
252
253                row.spawn((
254                    ResetButton,
255                    Node {
256                        padding: UiRect::axes(Val::Px(20.0), Val::Px(10.0)),
257                        border_radius: BorderRadius::all(Val::Px(6.0)),
258                        ..default()
259                    },
260                    BackgroundColor(COLOR_RESET_IDLE),
261                ))
262                .with_children(|btn| {
263                    btn.spawn((
264                        Text::new("Reset"),
265                        TextFont::from_font_size(16.0),
266                        TextColor(Color::WHITE),
267                        Pickable::IGNORE,
268                    ));
269                })
270                .observe(
271                    |_: On<PointerOver>, mut q: Single<&mut BackgroundColor, With<ResetButton>>| {
272                        q.0 = COLOR_RESET_HOVER;
273                    },
274                )
275                .observe(
276                    |_: On<PointerOut>, mut q: Single<&mut BackgroundColor, With<ResetButton>>| {
277                        q.0 = COLOR_RESET_IDLE;
278                    },
279                )
280                .observe(on_reset_click);
281            });
282        });
283}
284
285fn spawn_decoy_button(parent: &mut ChildSpawnerCommands, label: &str) {
286    parent
287        .spawn((
288            DecoyButton,
289            Node {
290                padding: UiRect::axes(Val::Px(24.0), Val::Px(14.0)),
291                border_radius: BorderRadius::all(Val::Px(6.0)),
292                justify_content: JustifyContent::Center,
293                align_items: AlignItems::Center,
294                ..default()
295            },
296            BackgroundColor(COLOR_DECOY_IDLE),
297        ))
298        .with_child((
299            Text::new(label),
300            TextFont::from_font_size(15.0),
301            TextColor(Color::WHITE),
302            Pickable::IGNORE,
303        ))
304        .observe(
305            |event: On<PointerOver>, mut colors: Query<&mut BackgroundColor, With<DecoyButton>>| {
306                if let Ok(mut color) = colors.get_mut(event.entity) {
307                    color.0 = COLOR_DECOY_HOVER;
308                }
309            },
310        )
311        .observe(
312            |event: On<PointerOut>, mut colors: Query<&mut BackgroundColor, With<DecoyButton>>| {
313                if let Ok(mut color) = colors.get_mut(event.entity) {
314                    color.0 = COLOR_DECOY_IDLE;
315                }
316            },
317        );
318}
319
320fn on_drag_start(
321    event: On<PointerDragStart>,
322    capture_enabled: Res<CaptureEnabled>,
323    mut capture_map: ResMut<PointerCaptureMap>,
324    mut fill_color: Single<&mut BackgroundColor, With<SliderFill>>,
325) {
326    if capture_enabled.0 {
327        capture_map.capture(event.pointer.id, event.entity, event.hit.clone());
328    }
329    // Signal active drag with a brighter fill color.
330    fill_color.0 = COLOR_FILL_DRAGGING;
331}
332
333fn on_drag(event: On<PointerDrag>, mut value: ResMut<SliderValue>) {
334    value.0 = (value.0 + event.delta.x / (TRACK_WIDTH - THUMB_SIZE)).clamp(0.0, 1.0);
335}
336
337fn on_drag_end(
338    event: On<PointerDragEnd>,
339    mut capture_map: ResMut<PointerCaptureMap>,
340    mut fill_color: Single<&mut BackgroundColor, With<SliderFill>>,
341) {
342    capture_map.release(event.pointer.id);
343    fill_color.0 = COLOR_FILL;
344}
345
346fn on_toggle_click(
347    _: On<PointerClick>,
348    mut capture_enabled: ResMut<CaptureEnabled>,
349    mut label: Single<&mut Text, With<ToggleLabel>>,
350    mut toggle_bg: Single<&mut BackgroundColor, With<ToggleButton>>,
351) {
352    capture_enabled.0 = !capture_enabled.0;
353    if capture_enabled.0 {
354        label.0 = "Capture: ON  (click to toggle)".into();
355        toggle_bg.0 = COLOR_TOGGLE_ON;
356    } else {
357        label.0 = "Capture: OFF  (click to toggle)".into();
358        toggle_bg.0 = COLOR_TOGGLE_OFF;
359    }
360}
361
362fn on_reset_click(_: On<PointerClick>, mut value: ResMut<SliderValue>) {
363    value.0 = SLIDER_DEFAULT_VALUE;
364}
365
366/// Moves the thumb and updates the numeric readout.
367fn sync_thumb_and_label(
368    value: Res<SliderValue>,
369    mut thumb: Single<&mut Node, With<SliderThumb>>,
370    mut value_label: Single<&mut Text, With<SliderLabel>>,
371) {
372    thumb.left = Val::Px(thumb_left(value.0));
373    value_label.0 = format!("Value: {:.2}", value.0);
374}
375
376/// Resizes the fill bar to match the current slider value.
377fn sync_fill(value: Res<SliderValue>, mut fill: Single<&mut Node, With<SliderFill>>) {
378    fill.width = Val::Px(fill_width(value.0));
379}