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