Skip to main content

material_ui_rs/widget/component/
segmented_button.rs

1//! Material 3 outlined segmented button constructors.
2
3use iced_widget::button::{Status, Style};
4use iced_widget::core::border::Radius;
5use iced_widget::core::text as core_text;
6use iced_widget::core::time::Instant;
7use iced_widget::core::{Background, Border, Color, Element, Length, Padding, Shadow, alignment};
8use iced_widget::graphics::geometry;
9use iced_widget::renderer::wgpu::primitive;
10use iced_widget::text;
11use iced_widget::{Container, Row, Text};
12
13use super::absolute_line_height;
14use super::button::Button;
15use super::support::{AnimatedScalar, duration_ms};
16use crate::utils::{mix, state_layer};
17use crate::{Theme, fonts, tokens};
18
19/// Animated segmented button selection state.
20#[derive(Debug, Clone)]
21pub struct State {
22    selected_index: usize,
23    previous_index: usize,
24    progress: AnimatedScalar,
25}
26
27impl State {
28    /// Creates segmented button selection state with the initial selected index.
29    pub fn new(selected_index: usize) -> Self {
30        Self {
31            selected_index,
32            previous_index: selected_index,
33            progress: AnimatedScalar::new(1.0),
34        }
35    }
36
37    /// Returns the selected segment index.
38    pub const fn selected_index(&self) -> usize {
39        self.selected_index
40    }
41
42    /// Starts the Material selection transition to `selected_index`.
43    pub fn select(&mut self, selected_index: usize, now: Instant) {
44        if self.selected_index == selected_index {
45            return;
46        }
47
48        self.previous_index = self.selected_index;
49        self.selected_index = selected_index;
50        self.progress = AnimatedScalar::new(0.0);
51        self.progress.set_target(
52            1.0,
53            now,
54            duration_ms(tokens::component::segmented_button::SELECT_TRANSITION_DURATION_MS),
55            tokens::component::segmented_button::SELECT_TRANSITION_EASING,
56        );
57    }
58
59    /// Advances the running transition.
60    pub fn advance(&mut self, now: Instant) -> bool {
61        self.progress.advance(now)
62    }
63
64    /// Returns whether a transition is still running.
65    pub fn is_animating(&self) -> bool {
66        self.progress.is_animating()
67    }
68
69    /// Returns this segment's selected-state visual progress.
70    pub fn progress_for(&self, index: usize) -> f32 {
71        let progress = self.progress.value.clamp(0.0, 1.0);
72
73        if index == self.selected_index {
74            progress
75        } else if index == self.previous_index {
76            1.0 - progress
77        } else {
78            0.0
79        }
80    }
81}
82
83/// A segment's visual position inside a segmented button set.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum SegmentPosition {
86    Only,
87    First,
88    Middle,
89    Last,
90}
91
92impl SegmentPosition {
93    /// Returns the segment position for an item in a segmented button set.
94    pub fn for_index(index: usize, len: usize) -> Self {
95        if len <= 1 {
96            Self::Only
97        } else if index == 0 {
98            Self::First
99        } else if index + 1 == len {
100            Self::Last
101        } else {
102            Self::Middle
103        }
104    }
105
106    fn radius(self) -> Radius {
107        let full = tokens::component::segmented_button::CONTAINER_SHAPE;
108
109        match self {
110            Self::Only => Radius::new(full),
111            Self::First => Radius {
112                top_left: full,
113                top_right: 0.0,
114                bottom_right: 0.0,
115                bottom_left: full,
116            },
117            Self::Middle => Radius::default(),
118            Self::Last => Radius {
119                top_left: 0.0,
120                top_right: full,
121                bottom_right: full,
122                bottom_left: 0.0,
123            },
124        }
125    }
126}
127
128fn segment_overlap_spacing() -> f32 {
129    -tokens::component::segmented_button::OUTLINE_WIDTH
130}
131
132/// Creates a row that holds segmented buttons with overlapping outlines.
133pub fn group<'a, Message, Renderer>(
134    segments: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
135) -> Row<'a, Message, Theme, Renderer>
136where
137    Message: 'a,
138    Renderer: iced_widget::core::Renderer + 'a,
139{
140    Row::with_children(segments)
141        .spacing(segment_overlap_spacing())
142        .align_y(alignment::Vertical::Center)
143}
144
145/// Creates a label-only outlined segment.
146pub fn label<'a, Message, Renderer>(
147    label: impl text::IntoFragment<'a>,
148    selected: bool,
149    position: SegmentPosition,
150) -> Button<'a, Message, Renderer>
151where
152    Message: Clone + 'a,
153    Renderer: geometry::Renderer + core_text::Renderer + 'a,
154{
155    segment_button(
156        Text::new(label)
157            .size(tokens::component::segmented_button::LABEL_TEXT.size)
158            .line_height(absolute_line_height(
159                tokens::component::segmented_button::LABEL_TEXT.line_height,
160            ))
161            .into(),
162        selected,
163        position,
164    )
165}
166
167/// Creates a label segment that shows the Material selected check icon only when selected.
168pub fn selectable_label<'a, Message, Renderer>(
169    label_text: impl text::IntoFragment<'a>,
170    selected: bool,
171    position: SegmentPosition,
172) -> Button<'a, Message, Renderer>
173where
174    Message: Clone + 'a,
175    Renderer: geometry::Renderer + core_text::Renderer + 'a,
176    iced_widget::core::Font: Into<Renderer::Font>,
177{
178    animated_selectable_label(label_text, if selected { 1.0 } else { 0.0 }, position)
179}
180
181/// Creates a label segment with animated selected check icon and fill progress.
182pub fn animated_selectable_label<'a, Message, Renderer>(
183    label: impl text::IntoFragment<'a>,
184    selected_progress: f32,
185    position: SegmentPosition,
186) -> Button<'a, Message, Renderer>
187where
188    Message: Clone + 'a,
189    Renderer: geometry::Renderer + core_text::Renderer + 'a,
190    iced_widget::core::Font: Into<Renderer::Font>,
191{
192    leading_icon_progress("check", label, selected_progress, position)
193}
194
195/// Creates an animated label segment with an action message.
196pub fn animated_label_action<'a, Message, Renderer>(
197    label: impl text::IntoFragment<'a>,
198    selected_progress: f32,
199    position: SegmentPosition,
200    on_press: Message,
201) -> Element<'a, Message, Theme, Renderer>
202where
203    Message: Clone + 'a,
204    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
205    iced_widget::core::Font: Into<Renderer::Font>,
206{
207    animated_selectable_label(label, selected_progress, position)
208        .on_press(on_press)
209        .into()
210}
211
212/// Creates animated label segments from the given selection state and actions.
213pub fn animated_label_actions<'a, Message, Renderer, Label>(
214    state: &State,
215    segments: impl IntoIterator<Item = (Label, Message)>,
216) -> Vec<Element<'a, Message, Theme, Renderer>>
217where
218    Label: text::IntoFragment<'a>,
219    Message: Clone + 'a,
220    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
221    iced_widget::core::Font: Into<Renderer::Font>,
222{
223    let segments: Vec<_> = segments.into_iter().collect();
224    let len = segments.len();
225
226    segments
227        .into_iter()
228        .enumerate()
229        .map(|(index, (label, on_press))| {
230            animated_label_action(
231                label,
232                state.progress_for(index),
233                SegmentPosition::for_index(index, len),
234                on_press,
235            )
236        })
237        .collect()
238}
239
240/// Creates an outlined segment with a Material Symbols leading icon.
241pub fn leading_icon<'a, Message, Renderer>(
242    icon_name: impl text::IntoFragment<'a>,
243    label: impl text::IntoFragment<'a>,
244    selected: bool,
245    position: SegmentPosition,
246) -> Button<'a, Message, Renderer>
247where
248    Message: Clone + 'a,
249    Renderer: geometry::Renderer + core_text::Renderer + 'a,
250    iced_widget::core::Font: Into<Renderer::Font>,
251{
252    leading_icon_progress(icon_name, label, if selected { 1.0 } else { 0.0 }, position)
253}
254
255fn leading_icon_progress<'a, Message, Renderer>(
256    icon_name: impl text::IntoFragment<'a>,
257    label: impl text::IntoFragment<'a>,
258    selected_progress: f32,
259    position: SegmentPosition,
260) -> Button<'a, Message, Renderer>
261where
262    Message: Clone + 'a,
263    Renderer: geometry::Renderer + core_text::Renderer + 'a,
264    iced_widget::core::Font: Into<Renderer::Font>,
265{
266    let progress = selected_progress.clamp(0.0, 1.0);
267    let label_text = tokens::component::segmented_button::LABEL_TEXT;
268    let icon = fonts::filled_icon(
269        icon_name,
270        tokens::component::segmented_button::WITH_ICON_ICON_SIZE * progress,
271    )
272    .width(Length::Fixed(
273        tokens::component::segmented_button::WITH_ICON_ICON_SIZE * progress,
274    ))
275    .height(Length::Fixed(
276        tokens::component::segmented_button::WITH_ICON_ICON_SIZE,
277    ));
278
279    let content = Row::<Message, Theme, Renderer>::new()
280        .push(icon)
281        .push(
282            Text::new(label)
283                .size(label_text.size)
284                .line_height(absolute_line_height(label_text.line_height)),
285        )
286        .spacing(tokens::component::segmented_button::ICON_LABEL_SPACE * progress)
287        .align_y(alignment::Vertical::Center);
288
289    segment_button_progress(content.into(), progress, position)
290}
291
292fn segment_button<'a, Message, Renderer>(
293    content: Element<'a, Message, Theme, Renderer>,
294    selected: bool,
295    position: SegmentPosition,
296) -> Button<'a, Message, Renderer>
297where
298    Message: Clone + 'a,
299    Renderer: geometry::Renderer + core_text::Renderer + 'a,
300{
301    segment_button_progress(content, if selected { 1.0 } else { 0.0 }, position)
302}
303
304fn segment_button_progress<'a, Message, Renderer>(
305    content: Element<'a, Message, Theme, Renderer>,
306    selected_progress: f32,
307    position: SegmentPosition,
308) -> Button<'a, Message, Renderer>
309where
310    Message: Clone + 'a,
311    Renderer: geometry::Renderer + core_text::Renderer + 'a,
312{
313    let progress = selected_progress.clamp(0.0, 1.0);
314
315    Button::new(
316        Container::new(content)
317            .height(Length::Fixed(
318                tokens::component::segmented_button::CONTAINER_HEIGHT,
319            ))
320            .padding(Padding {
321                top: 0.0,
322                right: tokens::component::segmented_button::TRAILING_SPACE,
323                bottom: 0.0,
324                left: tokens::component::segmented_button::LEADING_SPACE,
325            })
326            .align_y(alignment::Vertical::Center),
327    )
328    .height(Length::Fixed(
329        tokens::component::segmented_button::CONTAINER_HEIGHT,
330    ))
331    .padding(Padding::ZERO)
332    .style(move |theme, status| segmented_style_progress(theme, status, progress, position))
333}
334
335/// Returns the style for an outlined segmented button.
336pub fn segmented_style(
337    theme: &Theme,
338    status: Status,
339    selected: bool,
340    position: SegmentPosition,
341) -> Style {
342    segmented_style_progress(theme, status, if selected { 1.0 } else { 0.0 }, position)
343}
344
345/// Returns the style for an outlined segmented button at a selected-state progress.
346pub fn segmented_style_progress(
347    theme: &Theme,
348    status: Status,
349    selected_progress: f32,
350    position: SegmentPosition,
351) -> Style {
352    let colors = theme.colors();
353    let radius = position.radius();
354    let progress = selected_progress.clamp(0.0, 1.0);
355    let outline = colors.outline.color;
356    let container = if progress > 0.0 {
357        Some(Color {
358            a: colors.secondary.container.a * progress,
359            ..colors.secondary.container
360        })
361    } else {
362        None
363    };
364    let content = mix(
365        colors.surface.text,
366        colors.secondary.container_text,
367        progress,
368    );
369    let layer = mix(
370        colors.surface.text,
371        colors.secondary.container_text,
372        progress,
373    );
374
375    let border = Border {
376        color: outline,
377        width: tokens::component::segmented_button::OUTLINE_WIDTH,
378        radius,
379    };
380
381    let active = Style {
382        background: container.map(Background::Color),
383        text_color: content,
384        border,
385        shadow: Shadow::default(),
386        snap: cfg!(feature = "crisp"),
387    };
388
389    match status {
390        Status::Active => active,
391        Status::Hovered => Style {
392            background: Some(Background::Color(state_background(
393                container,
394                layer,
395                tokens::component::segmented_button::HOVER_STATE_LAYER_OPACITY,
396            ))),
397            ..active
398        },
399        Status::Pressed => Style {
400            background: Some(Background::Color(state_background(
401                container,
402                layer,
403                tokens::component::segmented_button::PRESSED_STATE_LAYER_OPACITY,
404            ))),
405            ..active
406        },
407        Status::Disabled => Style {
408            background: (progress > 0.0).then_some(Background::Color(Color {
409                a: tokens::state::DISABLED_CONTAINER_OPACITY,
410                ..colors.surface.text
411            })),
412            text_color: Color {
413                a: tokens::component::segmented_button::DISABLED_LABEL_TEXT_OPACITY,
414                ..colors.surface.text
415            },
416            border: Border {
417                color: Color {
418                    a: tokens::component::segmented_button::DISABLED_OUTLINE_OPACITY,
419                    ..colors.surface.text
420                },
421                ..border
422            },
423            ..active
424        },
425    }
426}
427
428fn state_background(container: Option<Color>, layer: Color, opacity: f32) -> Color {
429    container.map_or_else(
430        || state_layer(layer, opacity),
431        |color| mix(color, layer, opacity),
432    )
433}
434
435#[cfg(test)]
436#[path = "../../../tests/widget/component/segmented_button.rs"]
437mod tests;