tessera_ui_basic_components/
navigation_bar.rs

1//! Material Design 3 navigation bar for primary app destinations.
2//!
3//! ## Usage
4//!
5//! Use for bottom navigation between a small set of top-level destinations.
6use std::{
7    collections::HashMap,
8    sync::Arc,
9    time::{Duration, Instant},
10};
11
12use closure::closure;
13use derive_builder::Builder;
14use parking_lot::RwLock;
15use tessera_ui::{Color, DimensionValue, Dp, tessera};
16
17use crate::{
18    RippleState, ShadowProps,
19    alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
20    animation,
21    boxed::{BoxedArgsBuilder, boxed},
22    column::{ColumnArgsBuilder, column},
23    material_color::{MaterialColorScheme, global_material_scheme},
24    row::{RowArgsBuilder, row},
25    shape_def::Shape,
26    spacer::{SpacerArgsBuilder, spacer},
27    surface::{SurfaceArgsBuilder, SurfaceStyle, surface},
28    text::{TextArgsBuilder, text},
29};
30
31const ANIMATION_DURATION: Duration = Duration::from_millis(300);
32const CONTAINER_HEIGHT: Dp = Dp(80.0);
33const ITEM_PADDING: Dp = Dp(12.0);
34const LABEL_TEXT_SIZE: Dp = Dp(16.0);
35const LABEL_SPACING: Dp = Dp(4.0);
36const INDICATOR_WIDTH: Dp = Dp(56.0);
37const INDICATOR_HEIGHT: Dp = Dp(32.0);
38const DIVIDER_HEIGHT: Dp = Dp(1.0);
39const UNSELECTED_LABEL_ALPHA: f32 = 0.8;
40
41fn interpolate_color(from: Color, to: Color, progress: f32) -> Color {
42    Color {
43        r: from.r + (to.r - from.r) * progress,
44        g: from.g + (to.g - from.g) * progress,
45        b: from.b + (to.b - from.b) * progress,
46        a: from.a + (to.a - from.a) * progress,
47    }
48}
49
50/// Controls label visibility for a navigation bar item.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum NavigationBarLabelBehavior {
53    /// Always render the label.
54    AlwaysShow,
55    /// Fade the label in only when the item is selected.
56    SelectedOnly,
57}
58
59/// Item configuration for [`navigation_bar`].
60#[derive(Clone, Builder)]
61#[builder(pattern = "owned")]
62pub struct NavigationBarItem {
63    /// Text label shown under the icon.
64    #[builder(setter(into))]
65    pub label: String,
66    /// Optional icon rendered above the label.
67    #[builder(default, setter(strip_option))]
68    pub icon: Option<Arc<dyn Fn() + Send + Sync>>,
69    /// Callback invoked after selection changes to this item.
70    #[builder(default = "Arc::new(|| {})")]
71    pub on_click: Arc<dyn Fn() + Send + Sync>,
72    /// Whether the label is always visible or only appears when selected.
73    #[builder(default = "NavigationBarLabelBehavior::AlwaysShow")]
74    pub label_behavior: NavigationBarLabelBehavior,
75}
76
77/// # navigation_bar
78///
79/// Material navigation bar with active indicator and icon/label pairs.
80///
81/// ## Usage
82///
83/// Place at the bottom of the app to switch between 3–5 primary destinations.
84///
85/// ## Parameters
86///
87/// - `state` — see [`NavigationBarState`] to track which destination is active.
88/// - `scope_config` — closure that registers items via [`NavigationBarScope`].
89///
90/// ## Examples
91///
92/// ```
93/// use tessera_ui_basic_components::navigation_bar::{
94///     NavigationBarItemBuilder, NavigationBarState, navigation_bar,
95/// };
96///
97/// let state = NavigationBarState::new(0);
98/// navigation_bar(state.clone(), |scope| {
99///     scope.item(
100///         NavigationBarItemBuilder::default()
101///             .label("Home")
102///             .build()
103///             .unwrap(),
104///     );
105///     scope.item(
106///         NavigationBarItemBuilder::default()
107///             .label("Search")
108///             .build()
109///             .unwrap(),
110///     );
111/// });
112/// assert_eq!(state.selected(), 0);
113/// state.select(1);
114/// assert_eq!(state.selected(), 1);
115/// assert_eq!(state.previous_selected(), 0);
116/// ```
117#[tessera]
118pub fn navigation_bar<F>(state: NavigationBarState, scope_config: F)
119where
120    F: FnOnce(&mut NavigationBarScope),
121{
122    let mut items = Vec::new();
123    {
124        let mut scope = NavigationBarScope { items: &mut items };
125        scope_config(&mut scope);
126    }
127
128    let scheme = global_material_scheme();
129    let container_shadow = ShadowProps {
130        color: scheme.shadow.with_alpha(0.16),
131        offset: [0.0, 3.0],
132        smoothness: 10.0,
133    };
134
135    let animation_progress = state.animation_progress().unwrap_or(1.0);
136    let selected_index = state.selected();
137    let previous_index = state.previous_selected();
138
139    surface(
140        SurfaceArgsBuilder::default()
141            .width(DimensionValue::FILLED)
142            .height(CONTAINER_HEIGHT)
143            .style(scheme.surface.into())
144            .shadow(container_shadow)
145            .block_input(true)
146            .build()
147            .expect("SurfaceArgsBuilder failed with required fields set"),
148        None,
149        move || {
150            let separator_color = scheme.outline_variant.with_alpha(0.12);
151            column(
152                ColumnArgsBuilder::default()
153                    .width(DimensionValue::FILLED)
154                    .height(DimensionValue::FILLED)
155                    .cross_axis_alignment(CrossAxisAlignment::Stretch)
156                    .build()
157                    .expect("ColumnArgsBuilder failed with required fields set"),
158                move |column_scope| {
159                    column_scope.child(move || {
160                        surface(
161                            SurfaceArgsBuilder::default()
162                                .width(DimensionValue::FILLED)
163                                .height(DIVIDER_HEIGHT)
164                                .style(separator_color.into())
165                                .build()
166                                .expect("SurfaceArgsBuilder failed for divider"),
167                            None,
168                            || {},
169                        );
170                    });
171
172                    column_scope.child_weighted(
173                        move || {
174                            row(
175                                RowArgsBuilder::default()
176                                    .width(DimensionValue::FILLED)
177                                    .height(DimensionValue::FILLED)
178                                    .main_axis_alignment(MainAxisAlignment::SpaceEvenly)
179                                    .cross_axis_alignment(CrossAxisAlignment::Center)
180                                    .build()
181                                    .expect("RowArgsBuilder failed with required fields set"),
182                                move |row_scope| {
183                                    for (index, item) in items.into_iter().enumerate() {
184                                        let state_clone = state.clone();
185                                        let scheme_for_item = scheme.clone();
186                                        row_scope.child_weighted(
187                                            move || {
188                                                render_navigation_item(
189                                                    &state_clone,
190                                                    index,
191                                                    item,
192                                                    selected_index,
193                                                    previous_index,
194                                                    animation_progress,
195                                                    scheme_for_item,
196                                                );
197                                            },
198                                            1.0,
199                                        );
200                                    }
201                                },
202                            );
203                        },
204                        1.0,
205                    );
206                },
207            );
208        },
209    );
210}
211
212fn render_navigation_item(
213    state: &NavigationBarState,
214    index: usize,
215    item: NavigationBarItem,
216    selected_index: usize,
217    previous_index: usize,
218    animation_progress: f32,
219    scheme: MaterialColorScheme,
220) {
221    let is_selected = index == selected_index;
222    let was_selected = index == previous_index && selected_index != previous_index;
223    let selection_fraction = if is_selected {
224        animation_progress
225    } else if was_selected {
226        1.0 - animation_progress
227    } else {
228        0.0
229    };
230
231    let indicator_alpha = selection_fraction;
232    let content_color = interpolate_color(
233        scheme.on_surface_variant,
234        scheme.on_secondary_container,
235        selection_fraction,
236    );
237    let ripple_color = interpolate_color(
238        scheme.on_surface_variant.with_alpha(0.12),
239        scheme.on_secondary_container.with_alpha(0.12),
240        selection_fraction,
241    );
242
243    let label_alpha = match item.label_behavior {
244        NavigationBarLabelBehavior::AlwaysShow => {
245            selection_fraction + (1.0 - selection_fraction) * UNSELECTED_LABEL_ALPHA
246        }
247        NavigationBarLabelBehavior::SelectedOnly => selection_fraction,
248    };
249    let label_color = content_color.with_alpha(content_color.a * label_alpha);
250
251    let label_text = item.label.clone();
252    let icon_closure = item.icon.clone();
253    let indicator_color = scheme.secondary_container.with_alpha(indicator_alpha);
254
255    let ripple_state = state.ripple_state(index);
256    let icon_only_indicator_color = indicator_color;
257    let on_click = item.on_click.clone();
258
259    surface(
260        SurfaceArgsBuilder::default()
261            .width(DimensionValue::FILLED)
262            .height(DimensionValue::FILLED)
263            .style(SurfaceStyle::Filled {
264                color: Color::TRANSPARENT,
265            })
266            .shape(Shape::RECTANGLE)
267            .padding(ITEM_PADDING)
268            .ripple_color(ripple_color)
269            .hover_style(None)
270            .accessibility_label(label_text.clone())
271            .on_click(Arc::new(closure!(clone state, clone on_click, || {
272                if index != state.selected() {
273                    state.set_selected(index);
274                    on_click();
275                }
276            })))
277            .build()
278            .expect("SurfaceArgsBuilder failed with required fields set"),
279        Some(ripple_state),
280        move || {
281            let label_for_text = label_text.clone();
282            let label_color_for_text = label_color;
283            boxed(
284                BoxedArgsBuilder::default()
285                    .alignment(Alignment::Center)
286                    .width(DimensionValue::FILLED)
287                    .height(DimensionValue::FILLED)
288                    .build()
289                    .expect("BoxedArgsBuilder failed for item container"),
290                move |container| {
291                    container.child(move || {
292                        column(
293                            ColumnArgsBuilder::default()
294                                .width(DimensionValue::WRAP)
295                                .height(DimensionValue::WRAP)
296                                .main_axis_alignment(MainAxisAlignment::Center)
297                                .cross_axis_alignment(CrossAxisAlignment::Center)
298                                .build()
299                                .expect("ColumnArgsBuilder failed with required fields set"),
300                            move |column_scope| {
301                                let label_for_text = label_for_text.clone();
302                                let label_color = label_color_for_text;
303                                let has_icon = icon_closure.is_some();
304                                let icon_closure_for_stack = icon_closure.clone();
305                                column_scope.child(move || {
306                                    boxed(
307                                        BoxedArgsBuilder::default()
308                                        .alignment(Alignment::Center)
309                                        .build()
310                                        .expect("BoxedArgsBuilder failed for icon stack"),
311                                    move |icon_stack| {
312                                        let indicator_color = icon_only_indicator_color;
313                                        icon_stack.child(move || {
314                                            surface(
315                                                SurfaceArgsBuilder::default()
316                                                    .style(SurfaceStyle::Filled {
317                                                        color: indicator_color,
318                                                    })
319                                                    .shape(Shape::capsule())
320                                                    .width(INDICATOR_WIDTH)
321                                                    .height(INDICATOR_HEIGHT)
322                                                    .build()
323                                                    .expect("SurfaceArgsBuilder failed for indicator"),
324                                                None,
325                                                || {},
326                                            );
327                                        });
328
329                                        if let Some(draw_icon) = icon_closure_for_stack.clone()
330                                        {
331                                            icon_stack.child(move || {
332                                                draw_icon();
333                                                });
334                                            }
335                                        },
336                                    );
337                                });
338
339                                if !label_for_text.is_empty() {
340                                    if has_icon {
341                                        column_scope.child(move || {
342                                            spacer(
343                                                SpacerArgsBuilder::default()
344                                                    .height(LABEL_SPACING)
345                                                    .build()
346                                                    .expect(
347                                                        "SpacerArgsBuilder failed with required fields set",
348                                                    ),
349                                            );
350                                        });
351                                    }
352                                    let label = label_for_text.clone();
353                                    column_scope.child(move || {
354                                        text(
355                                            TextArgsBuilder::default()
356                                                .text(label)
357                                                .color(label_color)
358                                                .size(LABEL_TEXT_SIZE)
359                                                .build()
360                                                .expect("TextArgsBuilder failed with required fields set"),
361                                        );
362                                    });
363                                }
364                            },
365                        );
366                    });
367                },
368            );
369        },
370    );
371}
372
373/// Holds selection & per-item ripple state for the navigation bar.
374///
375/// `selected` tracks the currently active item index, while `ripple_states` lazily initializes
376/// per-item ripple data on first access.
377struct NavigationBarStateInner {
378    selected: usize,
379    previous_selected: usize,
380    ripple_states: HashMap<usize, RippleState>,
381    anim_start_time: Option<Instant>,
382}
383
384impl NavigationBarStateInner {
385    fn new(selected: usize) -> Self {
386        Self {
387            selected,
388            previous_selected: selected,
389            ripple_states: HashMap::new(),
390            anim_start_time: None,
391        }
392    }
393
394    fn set_selected(&mut self, index: usize) {
395        if self.selected != index {
396            self.previous_selected = self.selected;
397            self.selected = index;
398            self.anim_start_time = Some(Instant::now());
399        }
400    }
401
402    fn animation_progress(&mut self) -> Option<f32> {
403        if let Some(start_time) = self.anim_start_time {
404            let elapsed = start_time.elapsed();
405            if elapsed < ANIMATION_DURATION {
406                Some(animation::easing(
407                    elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32(),
408                ))
409            } else {
410                self.anim_start_time = None;
411                None
412            }
413        } else {
414            None
415        }
416    }
417
418    fn ripple_state(&mut self, index: usize) -> RippleState {
419        self.ripple_states.entry(index).or_default().clone()
420    }
421}
422
423/// Shared state for the `navigation_bar` component.
424///
425/// ## Examples
426///
427/// ```
428/// use tessera_ui_basic_components::navigation_bar::NavigationBarState;
429///
430/// let state = NavigationBarState::new(0);
431/// assert_eq!(state.selected(), 0);
432/// state.select(2);
433/// assert_eq!(state.selected(), 2);
434/// assert_eq!(state.previous_selected(), 0);
435/// ```
436#[derive(Clone)]
437pub struct NavigationBarState {
438    inner: Arc<RwLock<NavigationBarStateInner>>,
439}
440
441impl NavigationBarState {
442    /// Create a new state with an initial selected index.
443    pub fn new(selected: usize) -> Self {
444        Self {
445            inner: Arc::new(RwLock::new(NavigationBarStateInner::new(selected))),
446        }
447    }
448
449    /// Returns the index of the currently selected navigation item.
450    pub fn selected(&self) -> usize {
451        self.inner.read().selected
452    }
453
454    /// Returns the index of the previously selected navigation item.
455    pub fn previous_selected(&self) -> usize {
456        self.inner.read().previous_selected
457    }
458
459    /// Programmatically select an item by index.
460    pub fn select(&self, index: usize) {
461        self.inner.write().set_selected(index);
462    }
463
464    fn set_selected(&self, index: usize) {
465        self.inner.write().set_selected(index);
466    }
467
468    fn animation_progress(&self) -> Option<f32> {
469        self.inner.write().animation_progress()
470    }
471
472    fn ripple_state(&self, index: usize) -> RippleState {
473        self.inner.write().ripple_state(index)
474    }
475}
476
477impl Default for NavigationBarState {
478    fn default() -> Self {
479        Self::new(0)
480    }
481}
482
483/// Scope passed to the closure for defining children of the NavigationBar.
484pub struct NavigationBarScope<'a> {
485    items: &'a mut Vec<NavigationBarItem>,
486}
487
488impl<'a> NavigationBarScope<'a> {
489    /// Add a navigation item to the bar.
490    pub fn item<I>(&mut self, item: I)
491    where
492        I: Into<NavigationBarItem>,
493    {
494        self.items.push(item.into());
495    }
496}