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