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            focused: config.tonal_elevation,
94            pressed: config.tonal_elevation,
95            dragged: config.tonal_elevation,
96            disabled: 0.0,
97        });
98    }
99
100    ZStack(Modifier::new().fill_max_size()).child((
101        Box(Modifier::new()
102            .fill_max_size()
103            .background(config.content_color))
104        .child(content),
105        if drawer_state.is_open() {
106            Box(Modifier::new()
107                .fill_max_size()
108                .background(config.scrim_color)
109                .clickable()
110                .on_pointer_down({
111                    let ds = drawer_state.clone();
112                    move |_| ds.dismiss()
113                }))
114            .child(Box(Modifier::new()))
115        } else {
116            Box(Modifier::new())
117        },
118        Box(drawer_m).child(drawer_content),
119    ))
120}
121
122/// M3 Dismissible Navigation Drawer - slides alongside content without scrim.
123/// Uses [`DrawerState`] to control open/close.
124pub fn DismissibleNavigationDrawer(
125    drawer_state: Rc<DrawerState>,
126    drawer_content: View,
127    content: View,
128    config: NavigationDrawerConfig,
129) -> View {
130    let _th = theme();
131    let drawer_offset = animate_f32(
132        "dismissible_drawer_offset",
133        if drawer_state.is_open() { 0.0 } else { -360.0 },
134        theme().motion.spring,
135    );
136
137    let mut drawer_m = Modifier::new()
138        .absolute()
139        .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
140        .fill_max_height()
141        .width(config.width)
142        .background(config.container_color)
143        .clip_rounded(config.shape_radius);
144
145    if config.tonal_elevation > 0.0 {
146        drawer_m = drawer_m.state_elevation(StateElevation {
147            default: config.tonal_elevation,
148            hovered: config.tonal_elevation,
149            focused: config.tonal_elevation,
150            pressed: config.tonal_elevation,
151            dragged: config.tonal_elevation,
152            disabled: 0.0,
153        });
154    }
155
156    ZStack(Modifier::new().fill_max_size()).child((
157        Box(Modifier::new()
158            .fill_max_size()
159            .background(config.content_color))
160        .child(content),
161        Box(drawer_m).child(drawer_content),
162    ))
163}
164
165/// M3 Permanent Navigation Drawer - always visible alongside content.
166pub fn PermanentNavigationDrawer(
167    drawer_content: View,
168    content: View,
169    config: NavigationDrawerConfig,
170) -> View {
171    Row(Modifier::new().fill_max_size()).child((
172        Box(Modifier::new()
173            .width(config.width)
174            .fill_max_height()
175            .background(config.container_color))
176        .child(
177            Box(Modifier::new())
178                .color(config.content_color)
179                .child(drawer_content),
180        ),
181        Box(Modifier::new().flex_grow(1.0)).child(content),
182    ))
183}
184
185/// A destination entry inside a NavigationDrawer.
186#[derive(Clone)]
187pub struct NavigationDrawerItemConfig {
188    pub modifier: Modifier,
189    pub icon: Option<View>,
190    pub badge: Option<View>,
191    pub enabled: bool,
192    pub shape_radius: f32,
193    pub interaction_source: Option<MutableInteractionSource>,
194}
195
196impl Default for NavigationDrawerItemConfig {
197    fn default() -> Self {
198        Self {
199            modifier: Modifier::new(),
200            icon: None,
201            badge: None,
202            enabled: true,
203            shape_radius: repose_core::locals::theme().shapes.large,
204            interaction_source: None,
205        }
206    }
207}
208
209pub fn NavigationDrawerItem(
210    label: View,
211    selected: bool,
212    on_click: impl Fn() + 'static,
213    config: NavigationDrawerItemConfig,
214) -> View {
215    let th = theme();
216    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
217    let spec = th.motion.color;
218    let bg = animate_color(
219        format!("ndi_bg_{}", id),
220        if selected {
221            th.secondary_container
222        } else {
223            Color::TRANSPARENT
224        },
225        spec,
226    );
227    let fg = animate_color(
228        format!("ndi_fg_{}", id),
229        if selected {
230            th.on_secondary_container
231        } else {
232            th.on_surface_variant
233        },
234        spec,
235    );
236
237    let nd_source: Rc<MutableInteractionSource> = config
238        .interaction_source
239        .clone()
240        .map(Rc::new)
241        .unwrap_or_else(|| remember(MutableInteractionSource::new));
242
243    let mut m = Modifier::new()
244        .fill_max_width()
245        .padding_values(PaddingValues {
246            left: 12.0,
247            right: 12.0,
248            top: 0.0,
249            bottom: 0.0,
250        })
251        .min_height(56.0)
252        .background(bg)
253        .state_colors(StateColors {
254            default: Color::TRANSPARENT,
255            hovered: Color::TRANSPARENT,
256            focused: Color::TRANSPARENT,
257            pressed: Color::TRANSPARENT,
258            dragged: th.on_surface.with_alpha_f32(0.12),
259            disabled: Color::TRANSPARENT,
260        })
261        .clip_rounded(config.shape_radius)
262        .interaction_source(&nd_source)
263        .indication(crate::ripple::ripple(crate::ripple::RippleConfig {
264            color: Some(th.on_surface),
265            bounded: true,
266            ..Default::default()
267        }))
268        .then(config.modifier);
269
270    if config.enabled {
271        m = m.clickable().on_click(on_click);
272    }
273
274    Box(m).child(with_content_color(fg, || {
275        Row(Modifier::new()
276            .align_items(AlignItems::CENTER)
277            .padding_values(PaddingValues {
278                left: 16.0,
279                right: 24.0,
280                top: 0.0,
281                bottom: 0.0,
282            }))
283        .child((
284            config
285                .icon
286                .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
287            Box(Modifier::new().width(12.0).height(1.0)),
288            Box(Modifier::new().flex_grow(1.0)).child(label),
289            config.badge.unwrap_or(Box(Modifier::new())),
290        ))
291    }))
292}