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