Skip to main content

material_ui_rs/widget/component/
radio.rs

1//! Material 3 radio constructors with token-backed size and motion defaults.
2
3use super::*;
4
5type StyleFn<'a> = Box<dyn Fn(&Theme, iced_radio::Status) -> iced_radio::Style + 'a>;
6
7/// A Material 3 radio button with animated selected state transitions.
8pub struct Radio<'a, Message, Renderer = iced_widget::Renderer>
9where
10    Renderer: core_text::Renderer,
11{
12    is_selected: bool,
13    on_click: Option<Message>,
14    label: String,
15    width: Length,
16    size: f32,
17    spacing: f32,
18    text_size: Option<Pixels>,
19    text_line_height: LineHeight,
20    text_shaping: text::Shaping,
21    text_wrapping: text::Wrapping,
22    font: Option<Renderer::Font>,
23    style: StyleFn<'a>,
24}
25
26impl<Message, Renderer> std::fmt::Debug for Radio<'_, Message, Renderer>
27where
28    Message: std::fmt::Debug,
29    Renderer: core_text::Renderer,
30{
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("Radio")
33            .field("is_selected", &self.is_selected)
34            .field("on_click", &self.on_click)
35            .field("label", &self.label)
36            .field("width", &self.width)
37            .field("size", &self.size)
38            .field("spacing", &self.spacing)
39            .field("text_size", &self.text_size)
40            .field("text_line_height", &self.text_line_height)
41            .finish_non_exhaustive()
42    }
43}
44
45impl<'a, Message, Renderer> Radio<'a, Message, Renderer>
46where
47    Message: Clone,
48    Renderer: core_text::Renderer,
49{
50    pub fn new<F, V>(label: impl Into<String>, value: V, selected: Option<V>, on_select: F) -> Self
51    where
52        V: Eq + Copy,
53        F: FnOnce(V) -> Message,
54    {
55        Self {
56            is_selected: Some(value) == selected,
57            on_click: Some(on_select(value)),
58            label: label.into(),
59            width: Length::Shrink,
60            size: tokens::component::radio::ICON_SIZE,
61            spacing: f32::from(tokens::component::divider::LIST_ITEM_LEADING_SPACE),
62            text_size: Some(Pixels(tokens::component::radio::LABEL_TEXT_SIZE)),
63            text_line_height: absolute_line_height(
64                tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
65            ),
66            text_shaping: text::Shaping::default(),
67            text_wrapping: text::Wrapping::default(),
68            font: None,
69            style: Box::new(crate::style::radio::default),
70        }
71    }
72
73    pub fn on_select_maybe(mut self, on_click: Option<Message>) -> Self {
74        self.on_click = on_click;
75        self
76    }
77
78    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
79        self.size = size.into().0;
80        self
81    }
82
83    pub fn width(mut self, width: impl Into<Length>) -> Self {
84        self.width = width.into();
85        self
86    }
87
88    pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
89        self.spacing = spacing.into().0;
90        self
91    }
92
93    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
94        self.text_size = Some(text_size.into());
95        self
96    }
97
98    pub fn text_line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
99        self.text_line_height = line_height.into();
100        self
101    }
102
103    pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
104        self.text_shaping = shaping;
105        self
106    }
107
108    pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self {
109        self.text_wrapping = wrapping;
110        self
111    }
112
113    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
114        self.font = Some(font.into());
115        self
116    }
117
118    pub fn style(
119        mut self,
120        style: impl Fn(&Theme, iced_radio::Status) -> iced_radio::Style + 'a,
121    ) -> Self {
122        self.style = Box::new(style);
123        self
124    }
125}
126
127impl<Message, Renderer> Radio<'_, Message, Renderer>
128where
129    Renderer: core_text::Renderer,
130{
131    fn current_status(&self, bounds: Rectangle, cursor: mouse::Cursor) -> iced_radio::Status {
132        if cursor.is_over(bounds) && self.on_click.is_some() {
133            iced_radio::Status::Hovered {
134                is_selected: self.is_selected,
135            }
136        } else {
137            iced_radio::Status::Active {
138                is_selected: self.is_selected,
139            }
140        }
141    }
142
143    fn state_layer_color(
144        &self,
145        theme: &Theme,
146        state: &SelectionState<Renderer::Paragraph, iced_radio::Status>,
147        status: iced_radio::Status,
148    ) -> Option<Color> {
149        let is_hovered = matches!(status, iced_radio::Status::Hovered { .. });
150
151        if !state.is_pressed && !is_hovered {
152            return None;
153        }
154
155        let colors = theme.colors();
156        let (color, opacity) = if self.is_selected {
157            if state.is_pressed {
158                (
159                    colors.surface.text,
160                    tokens::state::PRESSED_STATE_LAYER_OPACITY,
161                )
162            } else {
163                (
164                    colors.primary.color,
165                    tokens::state::HOVER_STATE_LAYER_OPACITY,
166                )
167            }
168        } else if state.is_pressed {
169            (
170                colors.primary.color,
171                tokens::state::PRESSED_STATE_LAYER_OPACITY,
172            )
173        } else {
174            (
175                colors.surface.text,
176                tokens::state::HOVER_STATE_LAYER_OPACITY,
177            )
178        };
179
180        Some(alpha_color(color, opacity))
181    }
182}
183
184impl<Message, Renderer> Widget<Message, Theme, Renderer> for Radio<'_, Message, Renderer>
185where
186    Message: Clone,
187    Renderer: core_text::Renderer,
188{
189    fn tag(&self) -> tree::Tag {
190        tree::Tag::of::<SelectionState<Renderer::Paragraph, iced_radio::Status>>()
191    }
192
193    fn state(&self) -> tree::State {
194        let mut state =
195            SelectionState::<Renderer::Paragraph, iced_radio::Status>::new(self.is_selected);
196
197        state.size = AnimatedScalar::new(bool_value(self.is_selected));
198
199        tree::State::new(state)
200    }
201
202    fn size(&self) -> Size<Length> {
203        Size {
204            width: self.width,
205            height: Length::Shrink,
206        }
207    }
208
209    fn layout(
210        &mut self,
211        tree: &mut Tree,
212        renderer: &Renderer,
213        limits: &layout::Limits,
214    ) -> layout::Node {
215        layout::next_to_each_other(
216            &limits.width(self.width),
217            self.spacing,
218            |_| layout::Node::new(Size::new(self.size, self.size)),
219            |limits| {
220                let state = tree
221                    .state
222                    .downcast_mut::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
223
224                core_widget::text::layout(
225                    &mut state.text,
226                    renderer,
227                    limits,
228                    &self.label,
229                    core_widget::text::Format {
230                        width: self.width,
231                        height: Length::Shrink,
232                        line_height: self.text_line_height,
233                        size: self.text_size,
234                        font: self.font,
235                        align_x: text::Alignment::Default,
236                        align_y: alignment::Vertical::Top,
237                        shaping: self.text_shaping,
238                        wrapping: self.text_wrapping,
239                    },
240                )
241            },
242        )
243    }
244
245    fn update(
246        &mut self,
247        tree: &mut Tree,
248        event: &Event,
249        layout: Layout<'_>,
250        cursor: mouse::Cursor,
251        _renderer: &Renderer,
252        _clipboard: &mut dyn Clipboard,
253        shell: &mut Shell<'_, Message>,
254        _viewport: &Rectangle,
255    ) {
256        let state = tree
257            .state
258            .downcast_mut::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
259        let hit_bounds =
260            selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
261
262        match event {
263            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
264            | Event::Touch(touch::Event::FingerPressed { .. })
265                if self.on_click.is_some() && press_is_over(event, hit_bounds, cursor) =>
266            {
267                state.is_pressed = true;
268                state.press_origin = None;
269                shell.capture_event();
270                shell.request_redraw();
271            }
272            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
273            | Event::Touch(touch::Event::FingerLifted { .. })
274                if state.is_pressed =>
275            {
276                let is_released_over = release_is_over(event, hit_bounds, cursor);
277
278                state.is_pressed = false;
279                state.press_origin = None;
280
281                if is_released_over && let Some(on_click) = &self.on_click {
282                    shell.publish(on_click.clone());
283                }
284
285                shell.capture_event();
286                shell.request_redraw();
287            }
288            Event::Touch(touch::Event::FingerLost { .. }) if state.is_pressed => {
289                state.is_pressed = false;
290                state.press_origin = None;
291                shell.request_redraw();
292            }
293            _ => {}
294        }
295
296        let now = match event {
297            Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
298            _ => None,
299        };
300
301        if state.target != self.is_selected {
302            let now = now.unwrap_or_else(Instant::now);
303
304            state.target = self.is_selected;
305            state.color.set_target(
306                bool_value(self.is_selected),
307                now,
308                duration_ms(tokens::component::radio::ICON_COLOR_TRANSITION_DURATION_MS),
309                tokens::motion::EASING_LINEAR,
310            );
311
312            if self.is_selected {
313                state.size = AnimatedScalar::new(0.0);
314                state.size.set_target(
315                    1.0,
316                    now,
317                    duration_ms(tokens::component::radio::SELECT_TRANSITION_DURATION_MS),
318                    tokens::component::radio::SELECT_TRANSITION_EASING,
319                );
320            } else {
321                state.size = AnimatedScalar::new(1.0);
322            }
323
324            shell.request_redraw();
325        }
326
327        let current_status = self.current_status(hit_bounds, cursor);
328
329        if let Some(now) = now {
330            if state.advance(now) {
331                shell.request_redraw();
332            }
333
334            state.last_status = Some(current_status);
335        } else if state
336            .last_status
337            .is_some_and(|status| status != current_status)
338            || state.is_animating()
339        {
340            shell.request_redraw();
341        }
342    }
343
344    fn mouse_interaction(
345        &self,
346        _tree: &Tree,
347        layout: Layout<'_>,
348        cursor: mouse::Cursor,
349        _viewport: &Rectangle,
350        _renderer: &Renderer,
351    ) -> mouse::Interaction {
352        let hit_bounds =
353            selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
354
355        if cursor.is_over(hit_bounds) {
356            if self.on_click.is_some() {
357                mouse::Interaction::Pointer
358            } else {
359                mouse::Interaction::NotAllowed
360            }
361        } else {
362            mouse::Interaction::default()
363        }
364    }
365
366    fn draw(
367        &self,
368        tree: &Tree,
369        renderer: &mut Renderer,
370        theme: &Theme,
371        defaults: &renderer::Style,
372        layout: Layout<'_>,
373        _cursor: mouse::Cursor,
374        viewport: &Rectangle,
375    ) {
376        let state = tree
377            .state
378            .downcast_ref::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
379
380        let mut children = layout.children();
381        let control_layout = children.next().unwrap();
382        let bounds = control_layout.bounds();
383        let status = state.last_status.unwrap_or(iced_radio::Status::Active {
384            is_selected: self.is_selected,
385        });
386        let unchecked_status = match status {
387            iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: false },
388            iced_radio::Status::Hovered { .. } => {
389                iced_radio::Status::Hovered { is_selected: false }
390            }
391        };
392        let checked_status = match status {
393            iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: true },
394            iced_radio::Status::Hovered { .. } => iced_radio::Status::Hovered { is_selected: true },
395        };
396        let current_style = (self.style)(theme, status);
397        let unchecked_style = (self.style)(theme, unchecked_status);
398        let checked_style = (self.style)(theme, checked_status);
399
400        if let Some(layer_color) = self.state_layer_color(theme, state, status) {
401            renderer.fill_quad(
402                renderer::Quad {
403                    bounds: scaled_rect(
404                        bounds,
405                        tokens::component::radio::STATE_LAYER_SIZE,
406                        tokens::component::radio::STATE_LAYER_SIZE,
407                    ),
408                    border: border::rounded(tokens::component::radio::STATE_LAYER_SIZE / 2.0),
409                    ..renderer::Quad::default()
410                },
411                Background::Color(layer_color),
412            );
413        }
414
415        let color_progress = state.color.value.clamp(0.0, 1.0);
416        let icon_color = mix(
417            unchecked_style.border_color,
418            checked_style.border_color,
419            color_progress,
420        );
421        let radius = bounds.width.min(bounds.height) / 2.0;
422
423        renderer.fill_quad(
424            renderer::Quad {
425                bounds,
426                border: Border {
427                    radius: radius.into(),
428                    width: tokens::component::radio::OUTER_RING_WIDTH,
429                    color: icon_color,
430                },
431                ..renderer::Quad::default()
432            },
433            Color::TRANSPARENT,
434        );
435
436        if color_progress > 0.0 {
437            let dot_size =
438                tokens::component::radio::INNER_DOT_SIZE * state.size.value.clamp(0.0, 1.0);
439
440            if dot_size > 0.0 {
441                renderer.fill_quad(
442                    renderer::Quad {
443                        bounds: scaled_rect(bounds, dot_size, dot_size),
444                        border: border::rounded(dot_size / 2.0),
445                        ..renderer::Quad::default()
446                    },
447                    Background::Color(alpha_color(checked_style.dot_color, color_progress)),
448                );
449            }
450        }
451
452        let label_layout = children.next().unwrap();
453
454        core_widget::text::draw(
455            renderer,
456            defaults,
457            label_layout.bounds(),
458            state.text.raw(),
459            core_widget::text::Style {
460                color: current_style.text_color,
461            },
462            viewport,
463        );
464    }
465
466    fn operate(
467        &mut self,
468        _tree: &mut Tree,
469        layout: Layout<'_>,
470        _renderer: &Renderer,
471        operation: &mut dyn core_widget::Operation,
472    ) {
473        operation.text(None, layout.bounds(), &self.label);
474    }
475}
476
477impl<'a, Message, Renderer> From<Radio<'a, Message, Renderer>>
478    for Element<'a, Message, Theme, Renderer>
479where
480    Message: Clone + 'a,
481    Renderer: core_text::Renderer + 'a,
482{
483    fn from(radio: Radio<'a, Message, Renderer>) -> Self {
484        Element::new(radio)
485    }
486}
487
488pub fn control<'a, Message, Renderer, V>(
489    label: impl Into<String>,
490    value: V,
491    selected: Option<V>,
492    on_select: impl FnOnce(V) -> Message,
493) -> Radio<'a, Message, Renderer>
494where
495    Message: Clone + 'a,
496    Renderer: core_text::Renderer + 'a,
497    V: Eq + Copy,
498{
499    Radio::new(label, value, selected, on_select)
500        .size(tokens::component::radio::ICON_SIZE)
501        .spacing(f32::from(
502            tokens::component::divider::LIST_ITEM_LEADING_SPACE,
503        ))
504        .text_size(tokens::component::radio::LABEL_TEXT_SIZE)
505        .text_line_height(absolute_line_height(
506            tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
507        ))
508        .style(crate::style::radio::default)
509}
510
511pub fn standard<'a, Message, Renderer, V>(
512    label: impl Into<String>,
513    value: V,
514    selected: Option<V>,
515    on_select: impl FnOnce(V) -> Message,
516) -> Element<'a, Message, Theme, Renderer>
517where
518    Message: Clone + 'a,
519    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
520    V: Eq + Copy,
521{
522    Container::new(control(label, value, selected, on_select))
523        .center_y(Length::Fixed(tokens::component::radio::TARGET_SIZE))
524        .into()
525}