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