Skip to main content

repose_material/material3/
nav_bar.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use repose_core::animation::AnimationSpec;
7use repose_core::*;
8use repose_ui::{
9    Box, Column, Row, Text, TextStyle, ViewExt,
10    anim::{animate_color, animate_f32},
11};
12
13use super::*;
14
15static NAVBAR_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// Configuration for [`NavigationBar`].
18#[derive(Clone, Debug)]
19pub struct NavigationBarConfig {
20    pub modifier: Modifier,
21    pub container_color: Color,
22    pub content_color: Color,
23    pub selected_icon_color: Color,
24    pub selected_text_color: Color,
25    pub unselected_icon_color: Color,
26    pub unselected_text_color: Color,
27    pub indicator_color: Color,
28    pub height: f32,
29    pub tonal_elevation: f32,
30    pub indicator_opacity: f32,
31    pub indicator_radius: f32,
32    pub item_spacing: f32,
33    pub indicator_width: f32,
34    pub indicator_height: f32,
35}
36
37impl Default for NavigationBarConfig {
38    fn default() -> Self {
39        Self {
40            modifier: Modifier::new(),
41            container_color: NavigationBarDefaults::container_color(),
42            content_color: NavigationBarDefaults::content_color(),
43            selected_icon_color: NavigationBarDefaults::selected_icon_color(),
44            selected_text_color: NavigationBarDefaults::selected_text_color(),
45            unselected_icon_color: NavigationBarDefaults::unselected_icon_color(),
46            unselected_text_color: NavigationBarDefaults::unselected_text_color(),
47            indicator_color: NavigationBarDefaults::indicator_color(),
48            height: NavigationBarDefaults::HEIGHT,
49            tonal_elevation: NavigationBarDefaults::TONAL_ELEVATION,
50            indicator_opacity: NavigationBarDefaults::ITEM_ACTIVE_INDICATOR_OPACITY,
51            indicator_radius: NavigationBarDefaults::INDICATOR_RADIUS,
52            item_spacing: NavigationBarDefaults::ITEM_SPACING,
53            indicator_width: NavigationBarDefaults::ACTIVE_INDICATOR_WIDTH,
54            indicator_height: NavigationBarDefaults::ACTIVE_INDICATOR_HEIGHT,
55        }
56    }
57}
58
59/// M3 Navigation Bar - a bottom navigation bar with animated selection.
60/// Colors and indicator background transition with 200ms FastOutSlowIn.
61pub fn NavigationBar(
62    selected_index: usize,
63    items: Vec<NavItem>,
64    config: NavigationBarConfig,
65) -> View {
66    let th = theme();
67    let id = remember(|| NAVBAR_COUNTER.fetch_add(1, Ordering::Relaxed));
68
69    let mut bar_m = Modifier::new()
70        .fill_max_size()
71        .min_height(config.height)
72        .background(config.container_color)
73        .then(config.modifier);
74
75    if config.tonal_elevation > 0.0 {
76        bar_m = bar_m.state_elevation(StateElevation {
77            default: config.tonal_elevation,
78            hovered: config.tonal_elevation,
79            focused: config.tonal_elevation,
80            pressed: config.tonal_elevation,
81            dragged: config.tonal_elevation,
82            disabled: 0.0,
83        });
84    }
85
86    Box(bar_m).child(
87        Row(Modifier::new()
88            .fill_max_size()
89            .align_items(AlignItems::CENTER)
90            .column_gap(config.item_spacing)
91            .semantics(Semantics::new(Role::Container).with_selectable_group()))
92        .child(
93            items
94                .into_iter()
95                .enumerate()
96                .map(|(i, item)| {
97                    let selected = i == selected_index;
98                    let is_enabled = item.enabled;
99                    let default_effects = AnimationSpec::spring_crit(40.0);
100                    let fg_icon = animate_color(
101                        format!("nb_fi_{}_{}", id, i),
102                        if selected {
103                            config.selected_icon_color
104                        } else {
105                            config.unselected_icon_color
106                        },
107                        default_effects,
108                    );
109                    let fg_label = animate_color(
110                        format!("nb_fl_{}_{}", id, i),
111                        if selected {
112                            config.selected_text_color
113                        } else {
114                            config.unselected_text_color
115                        },
116                        default_effects,
117                    );
118                    let bg_alpha = animate_f32(
119                        format!("nb_bg_{}_{}", id, i),
120                        if selected { 1.0 } else { 0.0 },
121                        default_effects,
122                    );
123                    let indicator_bg = config
124                        .indicator_color
125                        .with_alpha_f32(bg_alpha * config.indicator_opacity);
126                    let cb = item.on_click.clone();
127                    let nb_source: Rc<MutableInteractionSource> = item
128                        .interaction_source
129                        .clone()
130                        .map(Rc::new)
131                        .unwrap_or_else(|| remember(MutableInteractionSource::new));
132
133                    let mut item_m = Modifier::new()
134                        .flex_grow(1.0)
135                        .interaction_source(&nb_source)
136                        .indication(crate::ripple::ripple(crate::ripple::RippleConfig {
137                            color: Some(theme().on_surface_variant),
138                            bounded: true,
139                            ..Default::default()
140                        }))
141                        .semantics(Semantics::new(Role::Tab).with_label(&item.label));
142
143                    if is_enabled {
144                        item_m = item_m.clickable().on_click({
145                            let cb = cb.clone();
146                            move || cb()
147                        });
148                    }
149
150                    Box(item_m).child(
151                        Column(
152                            Modifier::new()
153                                .fill_max_size()
154                                .align_items(AlignItems::CENTER)
155                                .justify_content(JustifyContent::CENTER),
156                        )
157                        .child((
158                            // Indicator pill behind icon
159                            Column(
160                                Modifier::new()
161                                    .align_items(AlignItems::CENTER)
162                                    .justify_content(JustifyContent::CENTER),
163                            )
164                            .child((
165                                Box(Modifier::new()
166                                    .absolute()
167                                    .offset(
168                                        Some((24.0 - config.indicator_width) / 2.0),
169                                        Some((24.0 - config.indicator_height) / 2.0),
170                                        None,
171                                        None,
172                                    )
173                                    .width(config.indicator_width)
174                                    .height(config.indicator_height)
175                                    .background(indicator_bg)
176                                    .clip_rounded(config.indicator_radius)
177                                    .state_colors(StateColors {
178                                        default: Color::TRANSPARENT,
179                                        hovered: Color::TRANSPARENT,
180                                        focused: Color::TRANSPARENT,
181                                        pressed: Color::TRANSPARENT,
182                                        dragged: th.on_surface.with_alpha_f32(0.12),
183                                        disabled: Color::TRANSPARENT,
184                                    })),
185                                with_content_color(fg_icon, move || item.icon),
186                            )),
187                            // 8dp gap: 4dp IndicatorVerticalPadding + 4dp IndicatorToLabelPadding
188                            Box(Modifier::new().height(8.0)),
189                            Text(item.label)
190                                .color(fg_label)
191                                .size(th.typography.label_medium)
192                                .single_line(),
193                        )),
194                    )
195                })
196                .collect::<Vec<_>>(),
197        ),
198    )
199}
200
201pub struct NavItem {
202    pub icon: View,
203    pub label: String,
204    pub on_click: Rc<dyn Fn()>,
205    pub enabled: bool,
206    pub interaction_source: Option<MutableInteractionSource>,
207}