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, 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.into_iter())
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, selected.then_some(1.0).unwrap_or(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_selectable_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_selectable_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_selectable_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(
253        icon_name,
254        label,
255        selected.then_some(1.0).unwrap_or(0.0),
256        position,
257    )
258}
259
260fn leading_icon_progress<'a, Message, Renderer>(
261    icon_name: impl text::IntoFragment<'a>,
262    label: impl text::IntoFragment<'a>,
263    selected_progress: f32,
264    position: SegmentPosition,
265) -> Button<'a, Message, Renderer>
266where
267    Message: Clone + 'a,
268    Renderer: geometry::Renderer + core_text::Renderer + 'a,
269    iced_widget::core::Font: Into<Renderer::Font>,
270{
271    let progress = selected_progress.clamp(0.0, 1.0);
272    let label_text = tokens::component::segmented_button::LABEL_TEXT;
273    let icon = fonts::filled_icon(
274        icon_name,
275        tokens::component::segmented_button::WITH_ICON_ICON_SIZE * progress,
276    )
277    .width(Length::Fixed(
278        tokens::component::segmented_button::WITH_ICON_ICON_SIZE * progress,
279    ))
280    .height(Length::Fixed(
281        tokens::component::segmented_button::WITH_ICON_ICON_SIZE,
282    ));
283
284    let content = Row::<Message, Theme, Renderer>::new()
285        .push(icon)
286        .push(
287            Text::new(label)
288                .size(label_text.size)
289                .line_height(absolute_line_height(label_text.line_height)),
290        )
291        .spacing(tokens::component::segmented_button::ICON_LABEL_SPACE * progress)
292        .align_y(alignment::Vertical::Center);
293
294    segment_button_progress(content.into(), progress, position)
295}
296
297fn segment_button<'a, Message, Renderer>(
298    content: Element<'a, Message, Theme, Renderer>,
299    selected: bool,
300    position: SegmentPosition,
301) -> Button<'a, Message, Renderer>
302where
303    Message: Clone + 'a,
304    Renderer: geometry::Renderer + core_text::Renderer + 'a,
305{
306    segment_button_progress(content, selected.then_some(1.0).unwrap_or(0.0), position)
307}
308
309fn segment_button_progress<'a, Message, Renderer>(
310    content: Element<'a, Message, Theme, Renderer>,
311    selected_progress: f32,
312    position: SegmentPosition,
313) -> Button<'a, Message, Renderer>
314where
315    Message: Clone + 'a,
316    Renderer: geometry::Renderer + core_text::Renderer + 'a,
317{
318    let progress = selected_progress.clamp(0.0, 1.0);
319
320    Button::new(
321        Container::new(content)
322            .height(Length::Fixed(
323                tokens::component::segmented_button::CONTAINER_HEIGHT,
324            ))
325            .padding(Padding {
326                top: 0.0,
327                right: tokens::component::segmented_button::TRAILING_SPACE,
328                bottom: 0.0,
329                left: tokens::component::segmented_button::LEADING_SPACE,
330            })
331            .align_y(alignment::Vertical::Center),
332    )
333    .height(Length::Fixed(
334        tokens::component::segmented_button::CONTAINER_HEIGHT,
335    ))
336    .padding(Padding::ZERO)
337    .style(move |theme, status| segmented_style_progress(theme, status, progress, position))
338}
339
340/// Returns the style for an outlined segmented button.
341pub fn segmented_style(
342    theme: &Theme,
343    status: Status,
344    selected: bool,
345    position: SegmentPosition,
346) -> Style {
347    segmented_style_progress(
348        theme,
349        status,
350        selected.then_some(1.0).unwrap_or(0.0),
351        position,
352    )
353}
354
355/// Returns the style for an outlined segmented button at a selected-state progress.
356pub fn segmented_style_progress(
357    theme: &Theme,
358    status: Status,
359    selected_progress: f32,
360    position: SegmentPosition,
361) -> Style {
362    let colors = theme.colors();
363    let radius = position.radius();
364    let progress = selected_progress.clamp(0.0, 1.0);
365    let outline = colors.outline.color;
366    let container = if progress > 0.0 {
367        Some(Color {
368            a: colors.secondary.container.a * progress,
369            ..colors.secondary.container
370        })
371    } else {
372        None
373    };
374    let content = mix(
375        colors.surface.text,
376        colors.secondary.container_text,
377        progress,
378    );
379    let layer = mix(
380        colors.surface.text,
381        colors.secondary.container_text,
382        progress,
383    );
384
385    let border = Border {
386        color: outline,
387        width: tokens::component::segmented_button::OUTLINE_WIDTH,
388        radius,
389    };
390
391    let active = Style {
392        background: container.map(Background::Color),
393        text_color: content,
394        border,
395        shadow: Default::default(),
396        snap: cfg!(feature = "crisp"),
397    };
398
399    match status {
400        Status::Active => active,
401        Status::Hovered => Style {
402            background: Some(Background::Color(state_background(
403                container,
404                layer,
405                tokens::component::segmented_button::HOVER_STATE_LAYER_OPACITY,
406            ))),
407            ..active
408        },
409        Status::Pressed => Style {
410            background: Some(Background::Color(state_background(
411                container,
412                layer,
413                tokens::component::segmented_button::PRESSED_STATE_LAYER_OPACITY,
414            ))),
415            ..active
416        },
417        Status::Disabled => Style {
418            background: (progress > 0.0).then_some(Background::Color(Color {
419                a: tokens::state::DISABLED_CONTAINER_OPACITY,
420                ..colors.surface.text
421            })),
422            text_color: Color {
423                a: tokens::component::segmented_button::DISABLED_LABEL_TEXT_OPACITY,
424                ..colors.surface.text
425            },
426            border: Border {
427                color: Color {
428                    a: tokens::component::segmented_button::DISABLED_OUTLINE_OPACITY,
429                    ..colors.surface.text
430                },
431                ..border
432            },
433            ..active
434        },
435    }
436}
437
438fn state_background(container: Option<Color>, layer: Color, opacity: f32) -> Color {
439    container.map_or_else(
440        || state_layer(layer, opacity),
441        |color| mix(color, layer, opacity),
442    )
443}
444
445#[cfg(test)]
446#[path = "../../../tests/widget/component/segmented_button.rs"]
447mod tests;