Skip to main content

egui_material3/
switch.rs

1use crate::get_global_color;
2use eframe::egui::{self, Color32, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, Widget};
3
4/// Material Design switch component following Material Design 3 specifications
5///
6/// Switches toggle the state of a single item on or off. They are the preferred way to
7/// adjust settings on mobile and should be used instead of checkboxes in most cases.
8///
9/// ## Usage Examples
10/// ```rust
11/// # egui::__run_test_ui(|ui| {
12/// let mut wifi_enabled = false;
13/// let mut bluetooth_enabled = true;
14///
15/// // Basic switch
16/// ui.add(MaterialSwitch::new(&mut wifi_enabled));
17///
18/// // Switch with label text
19/// ui.add(MaterialSwitch::new(&mut bluetooth_enabled)
20///     .text("Enable Bluetooth"));
21///
22/// // Disabled switch
23/// let mut disabled_option = false;
24/// ui.add(MaterialSwitch::new(&mut disabled_option)
25///     .text("Unavailable option")
26///     .enabled(false));
27/// # });
28/// ```
29///
30/// ## Material Design Spec (Material 3)
31/// - Track width: 52dp, height: 32dp
32/// - Thumb diameter: 24dp when on, 16dp when off, 28dp when pressed
33/// - Corner radius: 16dp (fully rounded)
34/// - Touch target: 48x48dp minimum
35/// - Colors: Primary when on, surfaceContainerHighest when off
36/// - Track outline: 2dp when off, transparent when on
37/// - Icons: 16dp, displayed on thumb
38/// - Animation: 300ms cubic-bezier transition
39pub struct MaterialSwitch<'a> {
40    /// Mutable reference to the switch state (on/off)
41    selected: &'a mut bool,
42    /// Optional text label displayed next to the switch
43    text: Option<String>,
44    /// Whether the switch is interactive (enabled/disabled)
45    enabled: bool,
46    /// Optional icon displayed on thumb when selected
47    selected_icon: Option<char>,
48    /// Optional icon displayed on thumb when unselected
49    unselected_icon: Option<char>,
50    /// Whether to show track outline (Material 3: true, Material 2: false)
51    show_track_outline: bool,
52}
53
54impl<'a> MaterialSwitch<'a> {
55    /// Create a new Material Design switch
56    ///
57    /// ## Parameters
58    /// - `selected`: Mutable reference to boolean state representing on/off
59    ///
60    /// ## Returns
61    /// A new MaterialSwitch instance with default settings
62    pub fn new(selected: &'a mut bool) -> Self {
63        Self {
64            selected,
65            text: None,
66            enabled: true,
67            selected_icon: None,
68            unselected_icon: None,
69            show_track_outline: true, // Material 3 default
70        }
71    }
72
73    /// Set optional text label for the switch
74    ///
75    /// The text will be displayed to the right of the switch component.
76    ///
77    /// ## Parameters
78    /// - `text`: Label text to display next to the switch
79    pub fn text(mut self, text: impl Into<String>) -> Self {
80        self.text = Some(text.into());
81        self
82    }
83
84    /// Set whether the switch is enabled or disabled
85    ///
86    /// Disabled switches cannot be interacted with and are visually dimmed.
87    ///
88    /// ## Parameters
89    /// - `enabled`: True for interactive, false for disabled
90    pub fn enabled(mut self, enabled: bool) -> Self {
91        self.enabled = enabled;
92        self
93    }
94
95    /// Set an icon to display on the thumb when the switch is selected (on)
96    ///
97    /// ## Parameters
98    /// - `icon`: Character representing a Material icon to display on selected thumb
99    pub fn selected_icon(mut self, icon: char) -> Self {
100        self.selected_icon = Some(icon);
101        self
102    }
103
104    /// Set an icon to display on the thumb when the switch is unselected (off)
105    ///
106    /// ## Parameters
107    /// - `icon`: Character representing a Material icon to display on unselected thumb
108    pub fn unselected_icon(mut self, icon: char) -> Self {
109        self.unselected_icon = Some(icon);
110        self
111    }
112
113    /// Set icons for both selected and unselected states
114    ///
115    /// ## Parameters
116    /// - `selected`: Character representing a Material icon to display when on
117    /// - `unselected`: Character representing a Material icon to display when off
118    pub fn with_icons(mut self, selected: char, unselected: char) -> Self {
119        self.selected_icon = Some(selected);
120        self.unselected_icon = Some(unselected);
121        self
122    }
123
124    /// Set whether to show track outline (Material 3 style)
125    ///
126    /// ## Parameters
127    /// - `show`: True to show outline when off, false for no outline
128    pub fn show_track_outline(mut self, show: bool) -> Self {
129        self.show_track_outline = show;
130        self
131    }
132}
133
134impl<'a> Widget for MaterialSwitch<'a> {
135    fn ui(self, ui: &mut Ui) -> Response {
136        // Material 3 specifications
137        let switch_width = 52.0;
138        let switch_height = 32.0;
139        let track_height = 32.0;
140
141        let desired_size = if let Some(ref text) = self.text {
142            let text_width = ui.fonts(|fonts| {
143                fonts.glyph_width(&egui::FontId::default(), ' ') * text.len() as f32
144            });
145            Vec2::new(switch_width + 8.0 + text_width, switch_height)
146        } else {
147            Vec2::new(switch_width, switch_height)
148        };
149
150        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
151
152        if response.clicked() && self.enabled {
153            *self.selected = !*self.selected;
154            response.mark_changed();
155        }
156
157        // Track interaction states
158        let is_pressed = response.is_pointer_button_down_on();
159        let is_hovered = response.hovered();
160        let is_focused = response.has_focus();
161
162        // Material Design 3 colors
163        let primary_color = get_global_color("primary");
164        let on_primary = get_global_color("onPrimary");
165        let primary_container = get_global_color("primaryContainer");
166        let on_primary_container = get_global_color("onPrimaryContainer");
167        let surface_container_highest = get_global_color("surfaceContainerHighest");
168        let on_surface = get_global_color("onSurface");
169        let on_surface_variant = get_global_color("onSurfaceVariant");
170        let outline = get_global_color("outline");
171
172        // Calculate switch area
173        let switch_rect = Rect::from_min_size(
174            Pos2::new(rect.min.x, rect.center().y - switch_height / 2.0),
175            Vec2::new(switch_width, switch_height),
176        );
177
178        let track_rect =
179            Rect::from_center_size(switch_rect.center(), Vec2::new(switch_width, track_height));
180
181        // Material 3: thumb is 16dp when off, 24dp when on, 28dp when pressed
182        let has_icon = if *self.selected {
183            self.selected_icon.is_some()
184        } else {
185            self.unselected_icon.is_some()
186        };
187        
188        let base_thumb_size_on = 24.0;
189        let base_thumb_size_off = if has_icon { 24.0 } else { 16.0 };
190        let pressed_thumb_size = 28.0;
191        
192        let thumb_size = if is_pressed {
193            pressed_thumb_size
194        } else if *self.selected {
195            base_thumb_size_on
196        } else {
197            base_thumb_size_off
198        };
199        
200        let thumb_travel = switch_width - base_thumb_size_on - 4.0;
201        let thumb_x = if *self.selected {
202            switch_rect.min.x + 2.0 + thumb_travel
203        } else {
204            switch_rect.min.x + 2.0
205        };
206
207        let thumb_center = Pos2::new(thumb_x + thumb_size / 2.0, switch_rect.center().y);
208
209        // Material 3 color resolution based on state
210        let (track_color, thumb_color, track_outline_color, icon_color) = if !self.enabled {
211            // Disabled state
212            let disabled_track = if *self.selected {
213                on_surface.linear_multiply(0.12)
214            } else {
215                surface_container_highest.linear_multiply(0.12)
216            };
217            let disabled_thumb = if *self.selected {
218                on_surface.linear_multiply(1.0)
219            } else {
220                on_surface.linear_multiply(0.38)
221            };
222            let disabled_outline = on_surface.linear_multiply(0.12);
223            let disabled_icon = if *self.selected {
224                on_surface.linear_multiply(0.38)
225            } else {
226                surface_container_highest.linear_multiply(0.38)
227            };
228            (disabled_track, disabled_thumb, disabled_outline, disabled_icon)
229        } else if *self.selected {
230            // Selected (on) state
231            let track = primary_color;
232            let thumb = if is_pressed || is_hovered || is_focused {
233                primary_container
234            } else {
235                on_primary
236            };
237            let outline = Color32::TRANSPARENT;
238            let icon = if is_pressed || is_hovered || is_focused {
239                on_primary_container
240            } else {
241                on_primary_container
242            };
243            (track, thumb, outline, icon)
244        } else {
245            // Unselected (off) state
246            let track = if is_pressed || is_hovered || is_focused {
247                surface_container_highest
248            } else {
249                surface_container_highest
250            };
251            let thumb = if is_pressed || is_hovered || is_focused {
252                on_surface_variant
253            } else {
254                outline
255            };
256            let track_outline = outline;
257            let icon = surface_container_highest;
258            (track, thumb, track_outline, icon)
259        };
260
261        // Draw track
262        ui.painter()
263            .rect_filled(track_rect, track_height / 2.0, track_color);
264
265        // Draw track outline (Material 3)
266        if self.show_track_outline && track_outline_color != Color32::TRANSPARENT {
267            ui.painter().rect_stroke(
268                track_rect,
269                track_height / 2.0,
270                Stroke::new(2.0, track_outline_color),
271                StrokeKind::Outside,
272            );
273        }
274
275        // Draw state layer (ripple/overlay effect) - Material 3
276        if self.enabled {
277            let overlay_radius = 20.0; // 40dp diameter / 2
278            let overlay_color = if *self.selected {
279                if is_pressed {
280                    primary_color.linear_multiply(0.1)
281                } else if is_hovered {
282                    primary_color.linear_multiply(0.08)
283                } else if is_focused {
284                    primary_color.linear_multiply(0.1)
285                } else {
286                    Color32::TRANSPARENT
287                }
288            } else {
289                if is_pressed {
290                    on_surface.linear_multiply(0.1)
291                } else if is_hovered {
292                    on_surface.linear_multiply(0.08)
293                } else if is_focused {
294                    on_surface.linear_multiply(0.1)
295                } else {
296                    Color32::TRANSPARENT
297                }
298            };
299
300            if overlay_color != Color32::TRANSPARENT {
301                ui.painter()
302                    .circle_filled(thumb_center, overlay_radius, overlay_color);
303            }
304        }
305
306        // Draw thumb
307        ui.painter()
308            .circle_filled(thumb_center, thumb_size / 2.0, thumb_color);
309
310        // Draw icon on thumb if present
311        let current_icon = if *self.selected {
312            self.selected_icon
313        } else {
314            self.unselected_icon
315        };
316
317        if let Some(icon) = current_icon {
318            let icon_size = 16.0;
319            let icon_font = FontId::proportional(icon_size);
320            
321            ui.painter().text(
322                thumb_center,
323                egui::Align2::CENTER_CENTER,
324                icon.to_string(),
325                icon_font,
326                icon_color,
327            );
328        }
329
330        // Draw label text
331        if let Some(ref text) = self.text {
332            let text_pos = Pos2::new(switch_rect.max.x + 8.0, rect.center().y);
333
334            let text_color = if self.enabled {
335                on_surface
336            } else {
337                on_surface.linear_multiply(0.38)
338            };
339
340            ui.painter().text(
341                text_pos,
342                egui::Align2::LEFT_CENTER,
343                text,
344                egui::FontId::default(),
345                text_color,
346            );
347        }
348
349        response
350    }
351}
352
353pub fn switch(selected: &mut bool) -> MaterialSwitch<'_> {
354    MaterialSwitch::new(selected)
355}