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