tessera_ui_basic_components/
navigation_bar.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum NavigationBarLabelBehavior {
53 AlwaysShow,
55 SelectedOnly,
57}
58
59#[derive(Clone, Builder)]
61#[builder(pattern = "owned")]
62pub struct NavigationBarItem {
63 #[builder(setter(into))]
65 pub label: String,
66 #[builder(default, setter(strip_option))]
68 pub icon: Option<Arc<dyn Fn() + Send + Sync>>,
69 #[builder(default = "Arc::new(|| {})")]
71 pub on_click: Arc<dyn Fn() + Send + Sync>,
72 #[builder(default = "NavigationBarLabelBehavior::AlwaysShow")]
74 pub label_behavior: NavigationBarLabelBehavior,
75}
76
77#[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
373struct 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#[derive(Clone)]
437pub struct NavigationBarState {
438 inner: Arc<RwLock<NavigationBarStateInner>>,
439}
440
441impl NavigationBarState {
442 pub fn new(selected: usize) -> Self {
444 Self {
445 inner: Arc::new(RwLock::new(NavigationBarStateInner::new(selected))),
446 }
447 }
448
449 pub fn selected(&self) -> usize {
451 self.inner.read().selected
452 }
453
454 pub fn previous_selected(&self) -> usize {
456 self.inner.read().previous_selected
457 }
458
459 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
483pub struct NavigationBarScope<'a> {
485 items: &'a mut Vec<NavigationBarItem>,
486}
487
488impl<'a> NavigationBarScope<'a> {
489 pub fn item<I>(&mut self, item: I)
491 where
492 I: Into<NavigationBarItem>,
493 {
494 self.items.push(item.into());
495 }
496}