Skip to main content

repose_material/material3/
nav_drawer.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::Ordering;
5
6use repose_core::*;
7use repose_ui::{
8    Box, Row, TextStyle,
9    ViewExt, ZStack,
10    anim::{animate_color, animate_f32},
11};
12
13use super::*;
14use super::util::FILTERCHIP_COUNTER;
15
16/// Configuration for [`NavigationDrawer`].
17#[derive(Clone, Debug)]
18pub struct NavigationDrawerConfig {
19    pub modifier: Modifier,
20    pub container_color: Color,
21    pub content_color: Color,
22    pub scrim_color: Color,
23    pub tonal_elevation: f32,
24    pub width: f32,
25    pub shape_radius: f32,
26}
27
28impl Default for NavigationDrawerConfig {
29    fn default() -> Self {
30        Self {
31            modifier: Modifier::new(),
32            container_color: NavigationDrawerDefaults::container_color(),
33            content_color: NavigationDrawerDefaults::content_color(),
34            scrim_color: NavigationDrawerDefaults::scrim_color(),
35            tonal_elevation: NavigationDrawerDefaults::TONAL_ELEVATION,
36            width: NavigationDrawerDefaults::WIDTH,
37            shape_radius: NavigationDrawerDefaults::SHAPE_RADIUS,
38        }
39    }
40}
41
42/// State controlling drawer open/close.
43pub struct DrawerState {
44    visible: Signal<bool>,
45}
46
47impl DrawerState {
48    pub fn new() -> Rc<Self> {
49        Rc::new(Self {
50            visible: signal(false),
51        })
52    }
53
54    pub fn is_open(&self) -> bool {
55        self.visible.get()
56    }
57
58    pub fn open(&self) {
59        self.visible.set(true);
60    }
61
62    pub fn dismiss(&self) {
63        self.visible.set(false);
64    }
65}
66
67/// A modal navigation drawer that slides in from the left with a scrim overlay.
68pub fn ModalNavigationDrawer(
69    drawer_state: Rc<DrawerState>,
70    drawer_content: View,
71    content: View,
72    config: NavigationDrawerConfig,
73) -> View {
74    let _th = theme();
75
76    let drawer_offset = animate_f32(
77        "modal_drawer_offset",
78        if drawer_state.is_open() { 0.0 } else { -360.0 },
79        theme().motion.spring,
80    );
81
82    let mut drawer_m = Modifier::new()
83        .absolute()
84        .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
85        .fill_max_height()
86        .width(config.width)
87        .background(config.container_color)
88        .clip_rounded(config.shape_radius);
89
90    if config.tonal_elevation > 0.0 {
91        drawer_m = drawer_m.state_elevation(StateElevation {
92            default: config.tonal_elevation,
93            hovered: config.tonal_elevation,
94            pressed: config.tonal_elevation,
95            disabled: 0.0,
96        });
97    }
98
99    ZStack(Modifier::new().fill_max_size()).child((
100        Box(Modifier::new()
101            .fill_max_size()
102            .background(config.content_color))
103        .child(content),
104        if drawer_state.is_open() {
105            Box(Modifier::new()
106                .fill_max_size()
107                .background(config.scrim_color)
108                .clickable()
109                .on_pointer_down({
110                    let ds = drawer_state.clone();
111                    move |_| ds.dismiss()
112                }))
113            .child(Box(Modifier::new()))
114        } else {
115            Box(Modifier::new())
116        },
117        Box(drawer_m).child(drawer_content),
118    ))
119}
120
121/// M3 Dismissible Navigation Drawer - slides alongside content without scrim.
122/// Uses [`DrawerState`] to control open/close.
123pub fn DismissibleNavigationDrawer(
124    drawer_state: Rc<DrawerState>,
125    drawer_content: View,
126    content: View,
127    config: NavigationDrawerConfig,
128) -> View {
129    let _th = theme();
130    let drawer_offset = animate_f32(
131        "dismissible_drawer_offset",
132        if drawer_state.is_open() { 0.0 } else { -360.0 },
133        theme().motion.spring,
134    );
135
136    let mut drawer_m = Modifier::new()
137        .absolute()
138        .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
139        .fill_max_height()
140        .width(config.width)
141        .background(config.container_color)
142        .clip_rounded(config.shape_radius);
143
144    if config.tonal_elevation > 0.0 {
145        drawer_m = drawer_m.state_elevation(StateElevation {
146            default: config.tonal_elevation,
147            hovered: config.tonal_elevation,
148            pressed: config.tonal_elevation,
149            disabled: 0.0,
150        });
151    }
152
153    ZStack(Modifier::new().fill_max_size()).child((
154        Box(Modifier::new()
155            .fill_max_size()
156            .background(config.content_color))
157        .child(content),
158        Box(drawer_m).child(drawer_content),
159    ))
160}
161
162/// M3 Permanent Navigation Drawer - always visible alongside content.
163pub fn PermanentNavigationDrawer(
164    drawer_content: View,
165    content: View,
166    config: NavigationDrawerConfig,
167) -> View {
168    Row(Modifier::new().fill_max_size()).child((
169        Box(Modifier::new()
170            .width(config.width)
171            .fill_max_height()
172            .background(config.container_color))
173        .child(
174            Box(Modifier::new())
175                .color(config.content_color)
176                .child(drawer_content),
177        ),
178        Box(Modifier::new().flex_grow(1.0)).child(content),
179    ))
180}
181
182/// A destination entry inside a NavigationDrawer.
183#[derive(Clone)]
184pub struct NavigationDrawerItemConfig {
185    pub modifier: Modifier,
186    pub icon: Option<View>,
187    pub badge: Option<View>,
188    pub enabled: bool,
189    pub shape_radius: f32,
190    pub interaction_source: Option<MutableInteractionSource>,
191}
192
193impl Default for NavigationDrawerItemConfig {
194    fn default() -> Self {
195        Self {
196            modifier: Modifier::new(),
197            icon: None,
198            badge: None,
199            enabled: true,
200            shape_radius: repose_core::locals::theme().shapes.large,
201            interaction_source: None,
202        }
203    }
204}
205
206pub fn NavigationDrawerItem(
207    label: View,
208    selected: bool,
209    on_click: impl Fn() + 'static,
210    config: NavigationDrawerItemConfig,
211) -> View {
212    let th = theme();
213    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
214    let spec = th.motion.color;
215    let bg = animate_color(
216        format!("ndi_bg_{}", id),
217        if selected {
218            th.secondary_container
219        } else {
220            Color::TRANSPARENT
221        },
222        spec,
223    );
224    let fg = animate_color(
225        format!("ndi_fg_{}", id),
226        if selected {
227            th.on_secondary_container
228        } else {
229            th.on_surface_variant
230        },
231        spec,
232    );
233
234    let nd_source: Rc<MutableInteractionSource> = config
235        .interaction_source
236        .clone()
237        .map(Rc::new)
238        .unwrap_or_else(|| remember(MutableInteractionSource::new));
239
240    let mut m = Modifier::new()
241        .fill_max_width()
242        .padding_values(PaddingValues {
243            left: 12.0,
244            right: 12.0,
245            top: 0.0,
246            bottom: 0.0,
247        })
248        .min_height(56.0)
249        .background(bg)
250        .state_colors(StateColors {
251            default: Color::TRANSPARENT,
252            hovered: th.on_surface.with_alpha_f32(0.08),
253            pressed: th.on_surface.with_alpha_f32(0.12),
254            disabled: Color::TRANSPARENT,
255        })
256        .clip_rounded(config.shape_radius)
257        .interaction_source(&*nd_source)
258        .then(config.modifier);
259
260    if config.enabled {
261        m = m.clickable().on_click(move || on_click());
262    }
263
264    Box(m).child(with_content_color(fg, || {
265        Row(Modifier::new()
266            .align_items(AlignItems::CENTER)
267            .padding_values(PaddingValues {
268                left: 16.0,
269                right: 24.0,
270                top: 0.0,
271                bottom: 0.0,
272            }))
273        .child((
274            config
275                .icon
276                .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
277            Box(Modifier::new().width(12.0).height(1.0)),
278            Box(Modifier::new().flex_grow(1.0)).child(label),
279            config.badge.unwrap_or(Box(Modifier::new())),
280        ))
281    }))
282}