Skip to main content

repose_material/material3/
nav_rail.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::{Box, Column, Text, TextStyle, ViewExt, anim::animate_color};
9
10use crate::ripple::{RippleConfig, ripple};
11
12use super::util::apply_m3_clickable_without_indication;
13use super::*;
14
15/// Configuration for [`NavigationRail`].
16#[derive(Clone, Debug)]
17pub struct NavigationRailConfig {
18    pub modifier: Modifier,
19    pub container_color: Color,
20    pub selected_icon_color: Color,
21    pub selected_text_color: Color,
22    pub unselected_icon_color: Color,
23    pub unselected_text_color: Color,
24    pub indicator_color: Color,
25    pub width: f32,
26    pub item_radius: f32,
27    pub indicator_opacity: f32,
28    pub item_spacing: f32,
29    pub indicator_width: f32,
30    pub indicator_height: f32,
31}
32
33impl Default for NavigationRailConfig {
34    fn default() -> Self {
35        Self {
36            modifier: Modifier::new(),
37            container_color: NavigationRailDefaults::container_color(),
38            selected_icon_color: NavigationRailDefaults::selected_icon_color(),
39            selected_text_color: NavigationRailDefaults::selected_text_color(),
40            unselected_icon_color: NavigationRailDefaults::unselected_icon_color(),
41            unselected_text_color: NavigationRailDefaults::unselected_text_color(),
42            indicator_color: NavigationRailDefaults::indicator_color(),
43            width: NavigationRailDefaults::WIDTH,
44            item_radius: NavigationRailDefaults::ITEM_RADIUS,
45            indicator_opacity: NavigationRailDefaults::ITEM_ACTIVE_INDICATOR_OPACITY,
46            item_spacing: NavigationRailDefaults::ITEM_SPACING,
47            indicator_width: NavigationRailDefaults::ACTIVE_INDICATOR_WIDTH,
48            indicator_height: NavigationRailDefaults::ACTIVE_INDICATOR_HEIGHT,
49        }
50    }
51}
52
53/// A destination entry inside a NavigationRail.
54pub struct NavRailItem {
55    pub icon: View,
56    pub label: String,
57    pub on_click: Rc<dyn Fn()>,
58    pub badge: Option<View>,
59    pub enabled: bool,
60    pub interaction_source: Option<MutableInteractionSource>,
61}
62
63static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
64
65/// M3 Navigation Rail - a compact vertical navigation sidebar.
66///
67/// Typically placed on the left side of the screen. Contains navigation items
68/// (icon + label) with animated selection indicator.
69pub fn NavigationRail(
70    selected_index: usize,
71    items: Vec<NavRailItem>,
72    header: Option<View>,
73    fab: Option<View>,
74    config: NavigationRailConfig,
75) -> View {
76    let th = theme();
77    let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
78    let default_effects = AnimationSpec::spring_crit(40.0);
79
80    let mut top_children: Vec<View> = Vec::new();
81    let mut item_views: Vec<View> = Vec::new();
82
83    let has_header = header.is_some();
84    let has_fab = fab.is_some();
85
86    if let Some(h) = header {
87        top_children.push(
88            Box(Modifier::new()
89                .padding_values(PaddingValues {
90                    left: 12.0,
91                    right: 12.0,
92                    top: 12.0,
93                    bottom: 12.0,
94                })
95                .align_self(AlignSelf::CENTER))
96            .child(h),
97        );
98    }
99
100    if let Some(f) = fab {
101        top_children.push(
102            Box(Modifier::new()
103                .padding_values(PaddingValues {
104                    left: 12.0,
105                    right: 12.0,
106                    top: 8.0,
107                    bottom: 8.0,
108                })
109                .align_self(AlignSelf::CENTER))
110            .child(f),
111        );
112    }
113
114    if has_header || has_fab {
115        top_children.push(Box(Modifier::new()
116            .fill_max_width()
117            .height(1.0)
118            .background(th.outline_variant)));
119    }
120
121    for (i, item) in items.into_iter().enumerate() {
122        let selected = i == selected_index;
123        let is_enabled = item.enabled;
124
125        let fg = animate_color(
126            format!("nr_fg_{}_{}", id, i),
127            if selected {
128                config.selected_icon_color
129            } else {
130                config.unselected_icon_color
131            },
132            default_effects,
133        );
134        let fg_label = animate_color(
135            format!("nr_fl_{}_{}", id, i),
136            if selected {
137                config.selected_text_color
138            } else {
139                config.unselected_text_color
140            },
141            default_effects,
142        );
143        let bg = animate_color(
144            format!("nr_bg_{}_{}", id, i),
145            if selected {
146                config.indicator_color
147            } else {
148                Color::TRANSPARENT
149            },
150            default_effects,
151        );
152
153        let cb = item.on_click.clone();
154        let nr_source: Rc<MutableInteractionSource> = item
155            .interaction_source
156            .clone()
157            .map(Rc::new)
158            .unwrap_or_else(|| remember(MutableInteractionSource::new));
159
160        // Track item width for MappedInteractionSource offset (like NavigationBar)
161        let item_width = remember_state_with_key(format!("rail_w_{}_{}", id, i), || 0.0f32);
162        let mut item_m = Modifier::new()
163            .fill_max_width()
164            .padding_values(PaddingValues {
165                left: 4.0,
166                right: 4.0,
167                top: 4.0,
168                bottom: 4.0,
169            })
170            .align_items(AlignItems::CENTER)
171            .justify_content(JustifyContent::CENTER)
172            .on_size_changed({
173                let w = item_width.clone();
174                move |size| *w.borrow_mut() = size.x
175            })
176            .semantics(Semantics::new(Role::Tab).with_label(&item.label));
177
178        item_m = apply_m3_clickable_without_indication(item_m, &nr_source, is_enabled, {
179            let cb = cb.clone();
180            move || cb()
181        });
182
183        // Pill background - now pill-sized, not full item
184        let bg_m = Modifier::new()
185            .absolute()
186            .offset(
187                Some((24.0 - config.indicator_width) / 2.0),
188                Some((24.0 - config.indicator_height) / 2.0),
189                None,
190                None,
191            )
192            .width(config.indicator_width)
193            .height(config.indicator_height)
194            .background(bg)
195            .clip_rounded(config.item_radius);
196
197        // Pill ripple host - handles hover/focus/press, mapped from outer item
198        let dx = (*item_width.borrow() - config.indicator_width) / 2.0;
199        let pill_m = Modifier::new()
200            .absolute()
201            .offset(
202                Some((24.0 - config.indicator_width) / 2.0),
203                Some((24.0 - config.indicator_height) / 2.0),
204                None,
205                None,
206            )
207            .width(config.indicator_width)
208            .height(config.indicator_height)
209            .clip_rounded(config.item_radius)
210            .interaction_source(&nr_source)
211            .indication(ripple(RippleConfig {
212                color: Some(th.on_surface),
213                bounded: true,
214                press_offset: if *item_width.borrow() > 0.0 {
215                    Some(Vec2 { x: dx, y: 0.0 })
216                } else {
217                    None
218                },
219                ..Default::default()
220            }));
221
222        item_views.push(
223            Column(item_m).child((
224                Column(Modifier::new()).child((
225                    Box(Modifier::new().size(24.0, 24.0)).child((
226                        Box(bg_m),
227                        with_content_color(fg, move || item.icon),
228                        Box(pill_m),
229                    )),
230                    item.badge
231                        .map(|b| {
232                            Box(Modifier::new()
233                                .absolute()
234                                .offset(None, None, None, Some(0.0)))
235                            .child(b)
236                        })
237                        .unwrap_or(Box(Modifier::new())),
238                )),
239                Box(Modifier::new().fill_max_width().height(4.0)),
240                Text(item.label)
241                    .color(fg_label)
242                    .size(th.typography.label_medium)
243                    .single_line(),
244            )),
245        );
246    }
247
248    Column(
249        Modifier::new()
250            .width(config.width)
251            .fill_max_height()
252            .background(config.container_color)
253            .align_items(AlignItems::CENTER)
254            .semantics(Semantics::new(Role::Container).with_selectable_group())
255            .then(config.modifier),
256    )
257    .child((
258        Column(Modifier::new()).with_children(top_children),
259        Box(Modifier::new().flex_grow(1.0)).child(
260            Column(
261                Modifier::new()
262                    .fill_max_size()
263                    .justify_content(JustifyContent::SPACE_BETWEEN)
264                    .align_items(AlignItems::CENTER),
265            )
266            .with_children(item_views),
267        ),
268    ))
269}