tessera_ui_basic_components/
bottom_nav_bar.rs

1//! Bottom navigation bar for switching between primary app screens.
2//!
3//! ## Usage
4//!
5//! Use for top-level navigation between a small number of primary application screens.
6use std::{
7    collections::HashMap,
8    sync::Arc,
9    time::{Duration, Instant},
10};
11
12use parking_lot::{Mutex, RwLock};
13use tessera_ui::{Color, DimensionValue, tessera};
14
15use crate::{
16    RippleState,
17    alignment::MainAxisAlignment,
18    animation,
19    button::{ButtonArgsBuilder, button},
20    pipelines::ShadowProps,
21    row::{RowArgsBuilder, row},
22    shape_def::Shape,
23    surface::{SurfaceArgsBuilder, surface},
24};
25
26const ANIMATION_DURATION: Duration = Duration::from_millis(300);
27const ACTIVE_COLOR: Color = Color::from_rgb_u8(225, 235, 255);
28const INACTIVE_COLOR: Color = Color::WHITE;
29const ACTIVE_COLOR_SHADOW: Color = Color::from_rgba_u8(100, 115, 140, 100);
30
31fn interpolate_color(from: Color, to: Color, progress: f32) -> Color {
32    Color {
33        r: from.r + (to.r - from.r) * progress,
34        g: from.g + (to.g - from.g) * progress,
35        b: from.b + (to.b - from.b) * progress,
36        a: from.a + (to.a - from.a) * progress,
37    }
38}
39
40/// # bottom_nav_bar
41///
42/// Provides a bottom navigation bar for switching between primary app screens.
43///
44/// ## Usage
45///
46/// Use for top-level navigation between a small number of primary application screens.
47///
48/// ## Parameters
49///
50/// - `state` — a clonable [`BottomNavBarState`] used to track the selected item.
51/// - `scope_config` — a closure that receives a [`BottomNavBarScope`] for adding navigation items.
52///
53/// ## Examples
54///
55/// ```
56/// use tessera_ui_basic_components::bottom_nav_bar::BottomNavBarState;
57///
58/// // Create a new state, starting with the first item (index 0) selected.
59/// let state = BottomNavBarState::new(0);
60/// assert_eq!(state.selected(), 0);
61///
62/// // The default state also selects the first item.
63/// let default_state = BottomNavBarState::default();
64/// assert_eq!(default_state.selected(), 0);
65/// ```
66#[tessera]
67pub fn bottom_nav_bar<F>(state: BottomNavBarState, scope_config: F)
68where
69    F: FnOnce(&mut BottomNavBarScope),
70{
71    let mut child_closures = Vec::new();
72
73    {
74        let mut scope = BottomNavBarScope {
75            child_closures: &mut child_closures,
76        };
77        scope_config(&mut scope);
78    }
79
80    let progress = state.animation_progress().unwrap_or(1.0);
81
82    surface(
83        SurfaceArgsBuilder::default()
84            .width(DimensionValue::FILLED)
85            .style(Color::from_rgb(9.333, 9.333, 9.333).into())
86            .shadow(ShadowProps::default())
87            .block_input(true)
88            .build()
89            .unwrap(),
90        None,
91        move || {
92            row(
93                RowArgsBuilder::default()
94                    .width(DimensionValue::FILLED)
95                    .main_axis_alignment(MainAxisAlignment::SpaceAround)
96                    .build()
97                    .unwrap(),
98                move |row_scope| {
99                    for (index, (child_content, on_click)) in child_closures.into_iter().enumerate()
100                    {
101                        let state_clone = state.clone();
102                        row_scope.child(move || {
103                            let selected = state_clone.selected();
104                            let previous_selected = state_clone.previous_selected();
105                            let ripple_state = state_clone.ripple_state(index);
106
107                            let color;
108                            let shadow_color;
109                            if index == selected {
110                                color = interpolate_color(INACTIVE_COLOR, ACTIVE_COLOR, progress);
111                                shadow_color =
112                                    interpolate_color(INACTIVE_COLOR, ACTIVE_COLOR_SHADOW, progress)
113                            } else if index == previous_selected {
114                                color = interpolate_color(ACTIVE_COLOR, INACTIVE_COLOR, progress);
115                                shadow_color =
116                                    interpolate_color(ACTIVE_COLOR_SHADOW, INACTIVE_COLOR, progress)
117                            } else {
118                                color = INACTIVE_COLOR;
119                                shadow_color = INACTIVE_COLOR;
120                            }
121
122                            let button_args = ButtonArgsBuilder::default()
123                                .color(color)
124                                .shape(Shape::HorizontalCapsule)
125                                .on_click(Arc::new(move || {
126                                    if index != selected {
127                                        state_clone.set_selected(index);
128                                        on_click.lock().take().unwrap()();
129                                    }
130                                }))
131                                .shadow(ShadowProps {
132                                    color: shadow_color,
133                                    ..Default::default()
134                                })
135                                .build()
136                                .unwrap();
137
138                            button(button_args, ripple_state, || {
139                                child_content();
140                            });
141                        });
142                    }
143                },
144            );
145        },
146    );
147}
148
149/// Holds selection & per-item ripple state for the bottom navigation bar.
150///
151/// `selected` is the currently active item index. `ripple_states` lazily allocates a
152/// `RippleState` (shared for each item) on first access, enabling ripple animations
153/// on its associated button.
154struct BottomNavBarStateInner {
155    selected: usize,
156    previous_selected: usize,
157    ripple_states: HashMap<usize, RippleState>,
158    anim_start_time: Option<Instant>,
159}
160
161impl BottomNavBarStateInner {
162    fn new(selected: usize) -> Self {
163        Self {
164            selected,
165            previous_selected: selected,
166            ripple_states: HashMap::new(),
167            anim_start_time: None,
168        }
169    }
170
171    fn set_selected(&mut self, index: usize) {
172        if self.selected != index {
173            self.previous_selected = self.selected;
174            self.selected = index;
175            self.anim_start_time = Some(Instant::now());
176        }
177    }
178
179    fn animation_progress(&mut self) -> Option<f32> {
180        if let Some(start_time) = self.anim_start_time {
181            let elapsed = start_time.elapsed();
182            if elapsed < ANIMATION_DURATION {
183                Some(animation::easing(
184                    elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32(),
185                ))
186            } else {
187                self.anim_start_time = None;
188                None
189            }
190        } else {
191            None
192        }
193    }
194
195    fn ripple_state(&mut self, index: usize) -> RippleState {
196        self.ripple_states.entry(index).or_default().clone()
197    }
198}
199
200#[derive(Clone)]
201pub struct BottomNavBarState {
202    inner: Arc<RwLock<BottomNavBarStateInner>>,
203}
204
205impl BottomNavBarState {
206    /// Create a new state with an initial selected index.
207    pub fn new(selected: usize) -> Self {
208        Self {
209            inner: Arc::new(RwLock::new(BottomNavBarStateInner::new(selected))),
210        }
211    }
212
213    /// Returns the index of the currently selected navigation item.
214    pub fn selected(&self) -> usize {
215        self.inner.read().selected
216    }
217
218    /// Returns the index of the previously selected navigation item.
219    pub fn previous_selected(&self) -> usize {
220        self.inner.read().previous_selected
221    }
222
223    fn set_selected(&self, index: usize) {
224        self.inner.write().set_selected(index);
225    }
226
227    fn animation_progress(&self) -> Option<f32> {
228        self.inner.write().animation_progress()
229    }
230
231    fn ripple_state(&self, index: usize) -> RippleState {
232        self.inner.write().ripple_state(index)
233    }
234}
235
236impl Default for BottomNavBarState {
237    fn default() -> Self {
238        Self::new(0)
239    }
240}
241
242/// Scope passed to the closure for defining children of the BottomNavBar.
243pub struct BottomNavBarScope<'a> {
244    child_closures: &'a mut Vec<(
245        Box<dyn FnOnce() + Send + Sync>,
246        Arc<Mutex<Option<Box<dyn FnOnce() + Send + Sync>>>>,
247    )>,
248}
249
250impl<'a> BottomNavBarScope<'a> {
251    /// Add a navigation item.
252    ///
253    /// * `child`: visual content (icon / label). Executed when the bar renders; must be
254    ///   side‑effect free except for building child components.
255    /// * `on_click`: invoked when this item is pressed; typical place for routing logic.
256    ///
257    /// The index of the added child becomes its selection index.
258    pub fn child<C, O>(&mut self, child: C, on_click: O)
259    where
260        C: FnOnce() + Send + Sync + 'static,
261        O: FnOnce() + Send + Sync + 'static,
262    {
263        self.child_closures.push((
264            Box::new(child),
265            Arc::new(Mutex::new(Some(Box::new(on_click)))),
266        ));
267    }
268}