tessera_ui_basic_components/bottom_nav_bar.rs
1//! A bottom navigation bar for switching between primary application screens.
2//!
3//! This module provides the [`bottom_nav_bar`] component, which creates a persistent,
4//! horizontal bar at the bottom of the UI. It is designed to work with a router to
5//! control which main screen or "shard" is currently visible.
6//!
7//! # Key Components
8//!
9//! * **[`bottom_nav_bar`]**: The main function that renders the navigation bar.
10//! * **[`BottomNavBarState`]**: A state object that must be created to track the
11//! currently selected navigation item.
12//! * **[`BottomNavBarScope`]**: A scope provided to the `bottom_nav_bar`'s closure
13//! to add individual navigation items.
14//!
15//! # Usage
16//!
17//! The typical layout involves placing the `bottom_nav_bar` in a `column` below a
18//! `router_root` component. This ensures the navigation bar remains visible while the
19//! content above it changes.
20//!
21//! 1. **Create State**: Create an `Arc<RwLock<BottomNavBarState>>` at a high level
22//! in your application state.
23//! 2. **Define Layout**: In your root component, create a `column`. Place a `router_root`
24//! in the first (weighted) child slot and the `bottom_nav_bar` in the second.
25//! 3. **Add Items**: Inside the `bottom_nav_bar` closure, use the provided scope's
26//! [`child`](BottomNavBarScope::child) method to add each navigation destination.
27//! - The first argument to `child` is a closure that renders the item's content (e.g., an icon or text).
28//! - The second argument is an `on_click` closure where you perform the navigation,
29//! typically by calling `tessera_ui::router::push` with the destination shard.
30//!
31//! # Example
32//!
33//! ```
34//! use std::sync::Arc;
35//! use parking_lot::RwLock;
36//! use tessera_ui::{tessera, router::{Router, router_root}};
37//! use tessera_ui_basic_components::{
38//! bottom_nav_bar::{bottom_nav_bar, BottomNavBarState},
39//! column::{ColumnArgsBuilder, column},
40//! text::{text, TextArgsBuilder},
41//! };
42//!
43//! // Assume HomeScreenDestination and ProfileScreenDestination are defined shards.
44//! # use tessera_ui::shard;
45//! # #[tessera] #[shard] fn home_screen() {}
46//! # #[tessera] #[shard] fn profile_screen() {}
47//!
48//! #[tessera]
49//! fn app_root() {
50//! let nav_bar_state = Arc::new(RwLock::new(BottomNavBarState::new(0)));
51//!
52//! column(ColumnArgsBuilder::default().build().unwrap(), move |scope| {
53//! // The router viewport takes up the remaining space.
54//! scope.child_weighted(|| {
55//! router_root(HomeScreenDestination {});
56//! }, 1.0);
57//!
58//! // The navigation bar is always visible at the bottom.
59//! scope.child(move || {
60//! bottom_nav_bar(nav_bar_state.clone(), |nav_scope| {
61//! // Add the "Home" item.
62//! nav_scope.child(
63//! || text(TextArgsBuilder::default().text("Home".to_string()).build().unwrap()),
64//! move || {
65//! Router::with_mut(|router| {
66//! router.reset_with(HomeScreenDestination {});
67//! });
68//! },
69//! );
70//!
71//! // Add the "Profile" item.
72//! nav_scope.child(
73//! || text(TextArgsBuilder::default().text("Profile".to_string()).build().unwrap()),
74//! move || {
75//! Router::with_mut(|router| {
76//! router.reset_with(ProfileScreenDestination {});
77//! });
78//! },
79//! );
80//! });
81//! });
82//! });
83//! }
84//! ```
85use std::{
86 collections::HashMap,
87 sync::Arc,
88 time::{Duration, Instant},
89};
90
91use parking_lot::{Mutex, RwLock};
92use tessera_ui::{Color, DimensionValue, tessera};
93
94use crate::{
95 RippleState,
96 alignment::MainAxisAlignment,
97 animation,
98 button::{ButtonArgsBuilder, button},
99 pipelines::ShadowProps,
100 row::{RowArgsBuilder, row},
101 shape_def::Shape,
102 surface::{SurfaceArgsBuilder, surface},
103};
104
105const ANIMATION_DURATION: Duration = Duration::from_millis(300);
106const ACTIVE_COLOR: Color = Color::from_rgb_u8(225, 235, 255);
107const INACTIVE_COLOR: Color = Color::WHITE;
108const ACTIVE_COLOR_SHADOW: Color = Color::from_rgba_u8(100, 115, 140, 100);
109
110fn interpolate_color(from: Color, to: Color, progress: f32) -> Color {
111 Color {
112 r: from.r + (to.r - from.r) * progress,
113 g: from.g + (to.g - from.g) * progress,
114 b: from.b + (to.b - from.b) * progress,
115 a: from.a + (to.a - from.a) * progress,
116 }
117}
118
119/// A horizontal bottom navigation bar that hosts multiple navigation items (children),
120/// each with its own click callback. The currently selected item is visually highlighted
121/// (pill style) and tracked inside a shared [`BottomNavBarState`].
122///
123/// # State Handling
124///
125/// * The `state: Arc<RwLock<BottomNavBarState>>` holds:
126/// - `selected`: index of the active item
127/// - A lazily created `RippleState` per item (for button ripple feedback)
128/// * The active item is rendered with a capsule shape & filled color; inactive items are
129/// rendered as transparent buttons.
130///
131/// # Building Children
132///
133/// Children are registered via the provided closure `scope_config` which receives a
134/// mutable [`BottomNavBarScope`]. Each child is added with:
135/// `scope.child(content_closure, on_click_closure)`.
136///
137/// `on_click_closure` is responsible for performing side effects (e.g. pushing a new route).
138/// The component automatically updates `selected` and triggers the ripple state before
139/// invoking the user `on_click`.
140///
141/// # Layout
142///
143/// Internally the bar is:
144/// * A full‑width `surface` (non‑interactive container)
145/// * A `row` whose children are spaced using `MainAxisAlignment::SpaceAround`
146///
147/// # Notes
148///
149/// * Indices are assigned in the order children are added.
150/// * The bar itself does not do routing — supply routing logic inside each child's
151/// `on_click` closure.
152/// * Thread safety for `selected` & ripple states is provided by `RwLock`.
153#[tessera]
154pub fn bottom_nav_bar<F>(state: Arc<RwLock<BottomNavBarState>>, scope_config: F)
155where
156 F: FnOnce(&mut BottomNavBarScope),
157{
158 let mut child_closures = Vec::new();
159
160 {
161 let mut scope = BottomNavBarScope {
162 child_closures: &mut child_closures,
163 };
164 scope_config(&mut scope);
165 }
166
167 let progress = {
168 let mut state = state.write();
169 state.animation_progress().unwrap_or(1.0)
170 };
171
172 surface(
173 SurfaceArgsBuilder::default()
174 .width(DimensionValue::FILLED)
175 .style(Color::from_rgb(9.333, 9.333, 9.333).into())
176 .shadow(ShadowProps::default())
177 .block_input(true)
178 .build()
179 .unwrap(),
180 None,
181 move || {
182 row(
183 RowArgsBuilder::default()
184 .width(DimensionValue::FILLED)
185 .main_axis_alignment(MainAxisAlignment::SpaceAround)
186 .build()
187 .unwrap(),
188 move |row_scope| {
189 for (index, (child_content, on_click)) in child_closures.into_iter().enumerate()
190 {
191 let state_clone = state.clone();
192 row_scope.child(move || {
193 let (selected, previous_selected) = {
194 let s = state_clone.read();
195 (s.selected(), s.previous_selected())
196 };
197 let ripple_state = state_clone.write().ripple_state(index);
198
199 let color;
200 let shadow_color;
201 if index == selected {
202 color = interpolate_color(INACTIVE_COLOR, ACTIVE_COLOR, progress);
203 shadow_color =
204 interpolate_color(INACTIVE_COLOR, ACTIVE_COLOR_SHADOW, progress)
205 } else if index == previous_selected {
206 color = interpolate_color(ACTIVE_COLOR, INACTIVE_COLOR, progress);
207 shadow_color =
208 interpolate_color(ACTIVE_COLOR_SHADOW, INACTIVE_COLOR, progress)
209 } else {
210 color = INACTIVE_COLOR;
211 shadow_color = INACTIVE_COLOR;
212 }
213
214 let button_args = ButtonArgsBuilder::default()
215 .color(color)
216 .shape(Shape::HorizontalCapsule)
217 .on_click(Arc::new(move || {
218 if index != selected {
219 state_clone.write().set_selected(index);
220 on_click.lock().take().unwrap()();
221 }
222 }))
223 .shadow(ShadowProps {
224 color: shadow_color,
225 ..Default::default()
226 })
227 .build()
228 .unwrap();
229
230 button(button_args, ripple_state, || {
231 child_content();
232 });
233 });
234 }
235 },
236 );
237 },
238 );
239}
240
241/// Holds selection & per-item ripple state for the bottom navigation bar.
242///
243/// `selected` is the currently active item index. `ripple_states` lazily allocates a
244/// `RippleState` (shared for each item) on first access, enabling ripple animations
245/// on its associated button.
246pub struct BottomNavBarState {
247 selected: usize,
248 previous_selected: usize,
249 ripple_states: HashMap<usize, Arc<RippleState>>,
250 anim_start_time: Option<Instant>,
251}
252
253impl Default for BottomNavBarState {
254 fn default() -> Self {
255 Self::new(0)
256 }
257}
258
259impl BottomNavBarState {
260 /// Create a new state with an initial selected index.
261 pub fn new(selected: usize) -> Self {
262 Self {
263 selected,
264 previous_selected: selected,
265 ripple_states: HashMap::new(),
266 anim_start_time: None,
267 }
268 }
269
270 /// Returns the index of the currently selected navigation item.
271 pub fn selected(&self) -> usize {
272 self.selected
273 }
274
275 /// Returns the index of the previously selected navigation item.
276 /// This is useful for animations when transitioning between selected items.
277 pub fn previous_selected(&self) -> usize {
278 self.previous_selected
279 }
280
281 fn set_selected(&mut self, index: usize) {
282 if self.selected != index {
283 self.previous_selected = self.selected;
284 self.selected = index;
285 self.anim_start_time = Some(Instant::now());
286 }
287 }
288
289 fn animation_progress(&mut self) -> Option<f32> {
290 if let Some(start_time) = self.anim_start_time {
291 let elapsed = start_time.elapsed();
292 if elapsed < ANIMATION_DURATION {
293 Some(animation::easing(
294 elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32(),
295 ))
296 } else {
297 self.anim_start_time = None;
298 None
299 }
300 } else {
301 None
302 }
303 }
304
305 fn ripple_state(&mut self, index: usize) -> Arc<RippleState> {
306 self.ripple_states
307 .entry(index)
308 .or_insert_with(|| Arc::new(RippleState::new()))
309 .clone()
310 }
311}
312
313/// Scope passed to the closure for defining children of the BottomNavBar.
314pub struct BottomNavBarScope<'a> {
315 child_closures: &'a mut Vec<(
316 Box<dyn FnOnce() + Send + Sync>,
317 Arc<Mutex<Option<Box<dyn FnOnce() + Send + Sync>>>>,
318 )>,
319}
320
321impl<'a> BottomNavBarScope<'a> {
322 /// Add a navigation item.
323 ///
324 /// * `child`: visual content (icon / label). Executed when the bar renders; must be
325 /// side‑effect free except for building child components.
326 /// * `on_click`: invoked when this item is pressed; typical place for routing logic.
327 ///
328 /// The index of the added child becomes its selection index.
329 pub fn child<C, O>(&mut self, child: C, on_click: O)
330 where
331 C: FnOnce() + Send + Sync + 'static,
332 O: FnOnce() + Send + Sync + 'static,
333 {
334 self.child_closures.push((
335 Box::new(child),
336 Arc::new(Mutex::new(Some(Box::new(on_click)))),
337 ));
338 }
339}