Skip to main content

material_ui_rs/widget/component/
tabs.rs

1//! Material 3 primary and secondary tab 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::{
8    Background, Border, Color, Element, Layout, Length, Padding, Rectangle, Size, Widget,
9    alignment, border, layout, mouse, renderer,
10};
11use iced_widget::graphics::geometry;
12use iced_widget::renderer::wgpu::primitive;
13use iced_widget::text;
14use iced_widget::{Column, Container, Row, Space, Text};
15
16use super::absolute_line_height;
17use super::button::Button;
18use super::support::{AnimatedScalar, duration_ms};
19use crate::utils::{mix, shadow_from_level};
20use crate::{Theme, fonts, tokens};
21
22/// Animated tab selection state.
23#[derive(Debug, Clone)]
24pub struct State {
25    selected_index: usize,
26    indicator_position: AnimatedScalar,
27}
28
29impl State {
30    /// Creates tab selection state with the initial selected index.
31    pub fn new(selected_index: usize) -> Self {
32        Self {
33            selected_index,
34            indicator_position: AnimatedScalar::new(selected_index as f32),
35        }
36    }
37
38    /// Returns the selected tab index.
39    pub const fn selected_index(&self) -> usize {
40        self.selected_index
41    }
42
43    /// Starts the Material tab indicator transition to `selected_index`.
44    pub fn select(&mut self, selected_index: usize, now: Instant, variant: Variant) {
45        if self.selected_index == selected_index {
46            return;
47        }
48
49        self.selected_index = selected_index;
50        self.indicator_position.set_target(
51            selected_index as f32,
52            now,
53            duration_ms(variant.indicator_animation_duration_ms()),
54            variant.indicator_animation_easing(),
55        );
56    }
57
58    /// Advances the running transition.
59    pub fn advance(&mut self, now: Instant) -> bool {
60        self.indicator_position.advance(now)
61    }
62
63    /// Returns whether the indicator transition is still running.
64    pub fn is_animating(&self) -> bool {
65        self.indicator_position.is_animating()
66    }
67
68    fn indicator_position(&self) -> f32 {
69        self.indicator_position.value
70    }
71}
72
73/// The Material tab variant.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Variant {
76    Primary,
77    Secondary,
78}
79
80impl Variant {
81    const fn container_height(self) -> f32 {
82        match self {
83            Self::Primary => tokens::component::primary_tab::CONTAINER_HEIGHT,
84            Self::Secondary => tokens::component::secondary_tab::CONTAINER_HEIGHT,
85        }
86    }
87
88    const fn indicator_height(self) -> f32 {
89        match self {
90            Self::Primary => tokens::component::primary_tab::ACTIVE_INDICATOR_HEIGHT,
91            Self::Secondary => tokens::component::secondary_tab::ACTIVE_INDICATOR_HEIGHT,
92        }
93    }
94
95    const fn label_text(self) -> tokens::typography::TypeScale {
96        match self {
97            Self::Primary => tokens::component::primary_tab::LABEL_TEXT,
98            Self::Secondary => tokens::component::secondary_tab::LABEL_TEXT,
99        }
100    }
101
102    const fn icon_size(self) -> f32 {
103        match self {
104            Self::Primary => tokens::component::primary_tab::ICON_SIZE,
105            Self::Secondary => tokens::component::secondary_tab::ICON_SIZE,
106        }
107    }
108
109    const fn indicator_animation_duration_ms(self) -> u16 {
110        match self {
111            Self::Primary => tokens::component::primary_tab::INDICATOR_ANIMATION_DURATION_MS,
112            Self::Secondary => tokens::component::secondary_tab::INDICATOR_ANIMATION_DURATION_MS,
113        }
114    }
115
116    const fn indicator_animation_easing(self) -> tokens::motion::CubicBezier {
117        match self {
118            Self::Primary => tokens::component::primary_tab::INDICATOR_ANIMATION_EASING,
119            Self::Secondary => tokens::component::secondary_tab::INDICATOR_ANIMATION_EASING,
120        }
121    }
122}
123
124/// How a tab renders its active indicator.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum IndicatorMode {
127    /// The tab paints its own indicator. Use with [`bar`].
128    Fixed,
129    /// The tab reserves room for a shared indicator. Use with [`animated_bar`]
130    /// or [`animated_tabs`].
131    Shared,
132}
133
134/// How an icon-label tab arranges the icon and label.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum IconLabelLayout {
137    /// Icon above label. Material defines this layout for primary tabs.
138    Stacked,
139    /// Icon and label in one row.
140    Inline,
141}
142
143/// The content shown inside a tab.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Content<'a> {
146    Label(text::Fragment<'a>),
147    IconLabel {
148        icon: text::Fragment<'a>,
149        label: text::Fragment<'a>,
150        layout: IconLabelLayout,
151    },
152}
153
154impl<'a> Content<'a> {
155    /// Creates label-only tab content.
156    pub fn label(label: impl text::IntoFragment<'a>) -> Self {
157        Self::Label(label.into_fragment())
158    }
159
160    /// Creates stacked icon-label tab content.
161    pub fn stacked_icon_label(
162        icon: impl text::IntoFragment<'a>,
163        label: impl text::IntoFragment<'a>,
164    ) -> Self {
165        Self::IconLabel {
166            icon: icon.into_fragment(),
167            label: label.into_fragment(),
168            layout: IconLabelLayout::Stacked,
169        }
170    }
171
172    /// Creates inline icon-label tab content.
173    pub fn inline_icon_label(
174        icon: impl text::IntoFragment<'a>,
175        label: impl text::IntoFragment<'a>,
176    ) -> Self {
177        Self::IconLabel {
178            icon: icon.into_fragment(),
179            label: label.into_fragment(),
180            layout: IconLabelLayout::Inline,
181        }
182    }
183}
184
185/// Creates an equal-width Material tab bar.
186pub fn bar<'a, Message, Renderer>(
187    tabs: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
188) -> Row<'a, Message, Theme, Renderer>
189where
190    Message: 'a,
191    Renderer: iced_widget::core::Renderer + 'a,
192{
193    Row::with_children(tabs)
194        .spacing(0)
195        .align_y(alignment::Vertical::Bottom)
196        .width(Length::Fill)
197}
198
199/// Creates an equal-width Material tab bar with an animated shared indicator.
200pub fn animated_bar<'a, Message, Renderer>(
201    variant: Variant,
202    tab_count: usize,
203    state: &State,
204    tabs: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
205) -> Column<'a, Message, Theme, Renderer>
206where
207    Message: 'a,
208    Renderer: iced_widget::core::Renderer + 'a,
209{
210    Column::new()
211        .push(bar(tabs))
212        .push(MovingIndicator {
213            variant,
214            tab_count,
215            position: state.indicator_position(),
216        })
217        .spacing(0)
218        .width(Length::Fill)
219}
220
221/// Creates a Material tab.
222pub fn tab<'a, Message, Renderer>(
223    variant: Variant,
224    content: Content<'a>,
225    active: bool,
226    indicator_mode: IndicatorMode,
227) -> Button<'a, Message, Renderer>
228where
229    Message: Clone + 'a,
230    Renderer: geometry::Renderer + core_text::Renderer + 'a,
231    iced_widget::core::Font: Into<Renderer::Font>,
232{
233    match content {
234        Content::Label(label) => label_tab(variant, label, active, indicator_mode),
235        Content::IconLabel {
236            icon,
237            label,
238            layout,
239        } => icon_label_tab(variant, icon, label, active, indicator_mode, layout),
240    }
241}
242
243/// Creates an animated Material tab bar from action items.
244pub fn animated_tabs<'a, Message, Renderer>(
245    variant: Variant,
246    state: &State,
247    tabs: impl IntoIterator<Item = (Content<'a>, Message)>,
248) -> Column<'a, Message, Theme, Renderer>
249where
250    Message: Clone + 'a,
251    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
252    iced_widget::core::Font: Into<Renderer::Font>,
253{
254    let tabs: Vec<_> = tabs
255        .into_iter()
256        .enumerate()
257        .map(|(index, (content, on_press))| {
258            tab(
259                variant,
260                content,
261                state.selected_index() == index,
262                IndicatorMode::Shared,
263            )
264            .on_press(on_press)
265            .into()
266        })
267        .collect();
268
269    animated_bar(variant, tabs.len(), state, tabs)
270}
271
272fn label_tab<'a, Message, Renderer>(
273    variant: Variant,
274    label: text::Fragment<'a>,
275    active: bool,
276    indicator_mode: IndicatorMode,
277) -> Button<'a, Message, Renderer>
278where
279    Message: Clone + 'a,
280    Renderer: geometry::Renderer + core_text::Renderer + 'a,
281{
282    let label_text = variant.label_text();
283    tab_button_for_mode(
284        variant,
285        Text::new(label)
286            .size(label_text.size)
287            .line_height(absolute_line_height(label_text.line_height))
288            .into(),
289        active,
290        variant.container_height(),
291        indicator_mode,
292    )
293}
294
295fn icon_label_tab<'a, Message, Renderer>(
296    variant: Variant,
297    icon: text::Fragment<'a>,
298    label: text::Fragment<'a>,
299    active: bool,
300    indicator_mode: IndicatorMode,
301    layout: IconLabelLayout,
302) -> Button<'a, Message, Renderer>
303where
304    Message: Clone + 'a,
305    Renderer: geometry::Renderer + core_text::Renderer + 'a,
306    iced_widget::core::Font: Into<Renderer::Font>,
307{
308    match (variant, layout) {
309        (Variant::Primary, IconLabelLayout::Stacked) => {
310            stacked_icon_label_tab(variant, icon, label, active, indicator_mode)
311        }
312        _ => inline_icon_label_tab(variant, icon, label, active, indicator_mode),
313    }
314}
315
316fn stacked_icon_label_tab<'a, Message, Renderer>(
317    variant: Variant,
318    icon: text::Fragment<'a>,
319    label: text::Fragment<'a>,
320    active: bool,
321    indicator_mode: IndicatorMode,
322) -> Button<'a, Message, Renderer>
323where
324    Message: Clone + 'a,
325    Renderer: geometry::Renderer + core_text::Renderer + 'a,
326    iced_widget::core::Font: Into<Renderer::Font>,
327{
328    let label_text = variant.label_text();
329    let content = Column::<Message, Theme, Renderer>::new()
330        .push(fonts::filled_icon(icon, variant.icon_size()))
331        .push(
332            Text::new(label)
333                .size(label_text.size)
334                .line_height(absolute_line_height(label_text.line_height)),
335        )
336        .spacing(tokens::component::primary_tab::STACKED_ICON_LABEL_SPACE)
337        .align_x(alignment::Horizontal::Center);
338
339    tab_button_for_mode(
340        variant,
341        content.into(),
342        active,
343        tokens::component::primary_tab::WITH_ICON_AND_LABEL_TEXT_CONTAINER_HEIGHT,
344        indicator_mode,
345    )
346}
347
348fn inline_icon_label_tab<'a, Message, Renderer>(
349    variant: Variant,
350    icon: text::Fragment<'a>,
351    label: text::Fragment<'a>,
352    active: bool,
353    indicator_mode: IndicatorMode,
354) -> Button<'a, Message, Renderer>
355where
356    Message: Clone + 'a,
357    Renderer: geometry::Renderer + core_text::Renderer + 'a,
358    iced_widget::core::Font: Into<Renderer::Font>,
359{
360    let label_text = variant.label_text();
361    let gap = match variant {
362        Variant::Primary => tokens::component::primary_tab::INLINE_ICON_LABEL_SPACE,
363        Variant::Secondary => tokens::component::secondary_tab::ICON_LABEL_SPACE,
364    };
365    let content = Row::<Message, Theme, Renderer>::new()
366        .push(fonts::filled_icon(icon, variant.icon_size()))
367        .push(
368            Text::new(label)
369                .size(label_text.size)
370                .line_height(absolute_line_height(label_text.line_height)),
371        )
372        .spacing(gap)
373        .align_y(alignment::Vertical::Center);
374
375    tab_button_for_mode(
376        variant,
377        content.into(),
378        active,
379        variant.container_height(),
380        indicator_mode,
381    )
382}
383
384fn tab_button_for_mode<'a, Message, Renderer>(
385    variant: Variant,
386    content: Element<'a, Message, Theme, Renderer>,
387    active: bool,
388    height: f32,
389    indicator_mode: IndicatorMode,
390) -> Button<'a, Message, Renderer>
391where
392    Message: Clone + 'a,
393    Renderer: geometry::Renderer + core_text::Renderer + 'a,
394{
395    match indicator_mode {
396        IndicatorMode::Fixed => tab_button(variant, content, active, height),
397        IndicatorMode::Shared => animated_tab_button(variant, content, active, height),
398    }
399}
400
401fn tab_button<'a, Message, Renderer>(
402    variant: Variant,
403    content: Element<'a, Message, Theme, Renderer>,
404    active: bool,
405    height: f32,
406) -> Button<'a, Message, Renderer>
407where
408    Message: Clone + 'a,
409    Renderer: geometry::Renderer + core_text::Renderer + 'a,
410{
411    tab_button_with_indicator(variant, content, active, height, true)
412}
413
414fn animated_tab_button<'a, Message, Renderer>(
415    variant: Variant,
416    content: Element<'a, Message, Theme, Renderer>,
417    active: bool,
418    height: f32,
419) -> Button<'a, Message, Renderer>
420where
421    Message: Clone + 'a,
422    Renderer: geometry::Renderer + core_text::Renderer + 'a,
423{
424    tab_button_with_indicator(
425        variant,
426        content,
427        active,
428        height - variant.indicator_height(),
429        false,
430    )
431}
432
433fn tab_button_with_indicator<'a, Message, Renderer>(
434    variant: Variant,
435    content: Element<'a, Message, Theme, Renderer>,
436    active: bool,
437    height: f32,
438    show_indicator: bool,
439) -> Button<'a, Message, Renderer>
440where
441    Message: Clone + 'a,
442    Renderer: geometry::Renderer + core_text::Renderer + 'a,
443{
444    let tab_content = Column::new().push(
445        Container::new(content)
446            .center_x(Length::Fill)
447            .center_y(Length::Fill)
448            .padding(Padding {
449                top: 0.0,
450                right: horizontal_space(variant),
451                bottom: 0.0,
452                left: horizontal_space(variant),
453            }),
454    );
455    let tab_content = if show_indicator {
456        tab_content.push(indicator(variant, active))
457    } else {
458        tab_content
459    }
460    .width(Length::Fill)
461    .height(Length::Fixed(height));
462
463    Button::new(tab_content)
464        .width(Length::Fill)
465        .height(Length::Fixed(height))
466        .padding(Padding::ZERO)
467        .style(move |theme, status| tab_style(theme, status, variant, active))
468}
469
470fn indicator<'a, Message, Renderer>(
471    variant: Variant,
472    active: bool,
473) -> Container<'a, Message, Theme, Renderer>
474where
475    Message: 'a,
476    Renderer: iced_widget::core::Renderer + 'a,
477{
478    Container::new(Space::new())
479        .width(Length::Fill)
480        .height(Length::Fixed(variant.indicator_height()))
481        .style(move |theme| indicator_style(theme, variant, active))
482}
483
484const fn horizontal_space(variant: Variant) -> f32 {
485    match variant {
486        Variant::Primary => tokens::component::primary_tab::HORIZONTAL_SPACE,
487        Variant::Secondary => tokens::component::secondary_tab::HORIZONTAL_SPACE,
488    }
489}
490
491/// Returns the container style for a Material tab.
492pub fn tab_style(theme: &Theme, status: Status, variant: Variant, active: bool) -> Style {
493    let colors = theme.colors();
494    let surface = colors.surface;
495    let content = tab_content_color(theme, variant, active, status);
496    let layer = tab_state_layer_color(theme, variant, active, status);
497    let container = surface.color;
498
499    let active_style = Style {
500        background: Some(Background::Color(container)),
501        text_color: content,
502        border: border::rounded(tab_container_shape(variant)),
503        shadow: shadow_from_level(tab_container_elevation(variant), colors.shadow),
504        snap: cfg!(feature = "crisp"),
505    };
506
507    match status {
508        Status::Active => active_style,
509        Status::Hovered => Style {
510            background: Some(Background::Color(mix(
511                container,
512                layer,
513                tab_hover_opacity(variant, active),
514            ))),
515            ..active_style
516        },
517        Status::Pressed => Style {
518            background: Some(Background::Color(mix(
519                container,
520                layer,
521                tab_pressed_opacity(variant, active),
522            ))),
523            ..active_style
524        },
525        Status::Disabled => Style {
526            background: Some(Background::Color(container)),
527            text_color: Color {
528                a: tokens::state::DISABLED_LABEL_TEXT_OPACITY,
529                ..surface.text
530            },
531            ..active_style
532        },
533    }
534}
535
536fn tab_content_color(theme: &Theme, variant: Variant, active: bool, status: Status) -> Color {
537    let colors = theme.colors();
538
539    match (variant, active, status) {
540        (Variant::Primary, true, _) => colors.primary.color,
541        (Variant::Primary, false, Status::Active) => colors.surface.text_variant,
542        (Variant::Primary, false, _) => colors.surface.text,
543        (Variant::Secondary, true, _) => colors.surface.text,
544        (Variant::Secondary, false, Status::Active) => colors.surface.text_variant,
545        (Variant::Secondary, false, _) => colors.surface.text,
546    }
547}
548
549fn tab_state_layer_color(theme: &Theme, variant: Variant, active: bool, status: Status) -> Color {
550    let colors = theme.colors();
551
552    match (variant, active, status) {
553        (Variant::Primary, true, _) => colors.primary.color,
554        (Variant::Primary, false, Status::Pressed) => colors.primary.color,
555        (Variant::Primary, false, _) => colors.surface.text,
556        (Variant::Secondary, _, _) => colors.surface.text,
557    }
558}
559
560const fn tab_hover_opacity(variant: Variant, active: bool) -> f32 {
561    match (variant, active) {
562        (Variant::Primary, true) => {
563            tokens::component::primary_tab::ACTIVE_HOVER_STATE_LAYER_OPACITY
564        }
565        (Variant::Primary, false) => {
566            tokens::component::primary_tab::INACTIVE_HOVER_STATE_LAYER_OPACITY
567        }
568        (Variant::Secondary, _) => tokens::component::secondary_tab::HOVER_STATE_LAYER_OPACITY,
569    }
570}
571
572const fn tab_pressed_opacity(variant: Variant, active: bool) -> f32 {
573    match (variant, active) {
574        (Variant::Primary, true) => {
575            tokens::component::primary_tab::ACTIVE_PRESSED_STATE_LAYER_OPACITY
576        }
577        (Variant::Primary, false) => {
578            tokens::component::primary_tab::INACTIVE_PRESSED_STATE_LAYER_OPACITY
579        }
580        (Variant::Secondary, _) => tokens::component::secondary_tab::PRESSED_STATE_LAYER_OPACITY,
581    }
582}
583
584const fn tab_container_shape(variant: Variant) -> f32 {
585    match variant {
586        Variant::Primary => tokens::component::primary_tab::CONTAINER_SHAPE,
587        Variant::Secondary => tokens::component::secondary_tab::CONTAINER_SHAPE,
588    }
589}
590
591const fn tab_container_elevation(variant: Variant) -> u8 {
592    match variant {
593        Variant::Primary => tokens::component::primary_tab::CONTAINER_ELEVATION_LEVEL,
594        Variant::Secondary => tokens::component::secondary_tab::CONTAINER_ELEVATION_LEVEL,
595    }
596}
597
598fn indicator_style(theme: &Theme, variant: Variant, active: bool) -> iced_widget::container::Style {
599    let colors = theme.colors();
600    let background = if active {
601        colors.primary.color
602    } else {
603        Color::TRANSPARENT
604    };
605
606    iced_widget::container::Style {
607        background: Some(Background::Color(background)),
608        border: Border {
609            color: Color::TRANSPARENT,
610            width: 0.0,
611            radius: indicator_radius(variant),
612        },
613        snap: cfg!(feature = "crisp"),
614        ..Default::default()
615    }
616}
617
618fn indicator_radius(variant: Variant) -> Radius {
619    match variant {
620        Variant::Primary => Radius {
621            top_left: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_TOP,
622            top_right: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_TOP,
623            bottom_right: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_BOTTOM,
624            bottom_left: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_BOTTOM,
625        },
626        Variant::Secondary => Radius::new(tokens::component::secondary_tab::ACTIVE_INDICATOR_SHAPE),
627    }
628}
629
630#[derive(Debug, Clone, Copy)]
631struct MovingIndicator {
632    variant: Variant,
633    tab_count: usize,
634    position: f32,
635}
636
637impl<Message, Renderer> Widget<Message, Theme, Renderer> for MovingIndicator
638where
639    Renderer: iced_widget::core::Renderer,
640{
641    fn size(&self) -> Size<Length> {
642        Size {
643            width: Length::Fill,
644            height: Length::Fixed(self.variant.indicator_height()),
645        }
646    }
647
648    fn layout(
649        &mut self,
650        _tree: &mut iced_widget::core::widget::Tree,
651        _renderer: &Renderer,
652        limits: &layout::Limits,
653    ) -> layout::Node {
654        layout::Node::new(limits.resolve(
655            Length::Fill,
656            Length::Fixed(self.variant.indicator_height()),
657            Size::ZERO,
658        ))
659    }
660
661    fn draw(
662        &self,
663        _tree: &iced_widget::core::widget::Tree,
664        renderer: &mut Renderer,
665        theme: &Theme,
666        _defaults: &renderer::Style,
667        layout: Layout<'_>,
668        _cursor: mouse::Cursor,
669        _viewport: &Rectangle,
670    ) {
671        if self.tab_count == 0 {
672            return;
673        }
674
675        let bounds = layout.bounds();
676        let tab_width = bounds.width / self.tab_count as f32;
677
678        if tab_width <= 0.0 {
679            return;
680        }
681
682        let position = self
683            .position
684            .clamp(0.0, self.tab_count.saturating_sub(1) as f32);
685        let indicator_width = moving_indicator_width(self.variant, tab_width);
686        let x = bounds.x + tab_width * position + (tab_width - indicator_width) / 2.0;
687        let indicator_bounds = Rectangle {
688            x,
689            y: bounds.y,
690            width: indicator_width,
691            height: self.variant.indicator_height(),
692        };
693
694        renderer.fill_quad(
695            renderer::Quad {
696                bounds: indicator_bounds,
697                border: Border {
698                    color: Color::TRANSPARENT,
699                    width: 0.0,
700                    radius: indicator_radius(self.variant),
701                },
702                snap: cfg!(feature = "crisp"),
703                ..renderer::Quad::default()
704            },
705            Background::Color(theme.colors().primary.color),
706        );
707    }
708}
709
710impl<'a, Message, Renderer> From<MovingIndicator> for Element<'a, Message, Theme, Renderer>
711where
712    Message: 'a,
713    Renderer: iced_widget::core::Renderer + 'a,
714{
715    fn from(indicator: MovingIndicator) -> Self {
716        Element::new(indicator)
717    }
718}
719
720fn moving_indicator_width(variant: Variant, tab_width: f32) -> f32 {
721    match variant {
722        Variant::Primary => (tab_width - horizontal_space(variant) * 2.0).max(0.0),
723        Variant::Secondary => tab_width,
724    }
725}
726
727#[cfg(test)]
728#[path = "../../../tests/widget/component/tabs.rs"]
729mod tests;