Skip to main content

repose_material/material3/
dropdown_menu.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use repose_core::*;
7use repose_ui::{
8    Box, Column, Row, Text, TextStyle,
9    ViewExt, ZStack,
10    overlay::OverlayHandle,
11};
12
13use super::*;
14use super::util::apply_tonal_elevation;
15
16/// Configuration for [`DropdownMenu`].
17#[derive(Clone, Debug)]
18pub struct DropdownMenuConfig {
19    pub modifier: Modifier,
20    pub container_color: Color,
21    pub item_text_color: Color,
22    pub disabled_item_text_color: Color,
23    pub divider_color: Color,
24    pub min_width: f32,
25    pub item_height: f32,
26    pub max_width: f32,
27    pub shadow_elevation: Option<f32>,
28    pub tonal_elevation: f32,
29    pub border: Option<(f32, Color, f32)>,
30    pub shape_radius: Option<f32>,
31    pub offset_x: f32,
32    pub offset_y: f32,
33    pub vertical_margin: f32,
34}
35
36impl Default for DropdownMenuConfig {
37    fn default() -> Self {
38        Self {
39            modifier: Modifier::new(),
40            container_color: DropdownMenuDefaults::container_color(),
41            item_text_color: DropdownMenuDefaults::item_text_color(),
42            disabled_item_text_color: DropdownMenuDefaults::disabled_item_text_color(),
43            divider_color: DropdownMenuDefaults::divider_color(),
44            min_width: DropdownMenuDefaults::MIN_WIDTH,
45            item_height: DropdownMenuDefaults::ITEM_HEIGHT,
46            max_width: DropdownMenuDefaults::MAX_WIDTH,
47            shadow_elevation: None,
48            tonal_elevation: 0.0,
49            border: None,
50            shape_radius: None,
51            offset_x: 0.0,
52            offset_y: 0.0,
53            vertical_margin: DropdownMenuDefaults::VERTICAL_MARGIN,
54        }
55    }
56}
57
58/// A single item inside a `DropdownMenu`.
59#[derive(Clone)]
60pub struct DropdownMenuItem {
61    pub text: String,
62    pub leading_icon: Option<View>,
63    pub trailing_icon: Option<View>,
64    pub on_click: Rc<dyn Fn()>,
65    pub enabled: bool,
66}
67
68impl DropdownMenuItem {
69    pub fn new(text: impl Into<String>, on_click: impl Fn() + 'static) -> Self {
70        Self {
71            text: text.into(),
72            leading_icon: None,
73            trailing_icon: None,
74            on_click: Rc::new(on_click),
75            enabled: true,
76        }
77    }
78
79    pub fn leading_icon(mut self, icon: View) -> Self {
80        self.leading_icon = Some(icon);
81        self
82    }
83
84    pub fn trailing_icon(mut self, icon: View) -> Self {
85        self.trailing_icon = Some(icon);
86        self
87    }
88
89    pub fn disabled(mut self) -> Self {
90        self.enabled = false;
91        self
92    }
93}
94
95/// A menu divider line.
96pub struct MenuDivider;
97
98/// State for controlling `DropdownMenu` visibility.
99pub struct MenuState {
100    visible: Signal<bool>,
101    anchor: Signal<Option<Vec2>>,
102}
103
104impl Default for MenuState {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl MenuState {
111    pub fn new() -> Self {
112        Self {
113            visible: signal(false),
114            anchor: signal(None),
115        }
116    }
117
118    pub fn is_open(&self) -> bool {
119        self.visible.get()
120    }
121
122    pub fn open(&self) {
123        self.visible.set(true);
124    }
125
126    pub fn open_at(&self, screen_pos: Vec2) {
127        self.anchor.set(Some(screen_pos));
128        self.visible.set(true);
129    }
130
131    pub fn dismiss(&self) {
132        self.visible.set(false);
133    }
134}
135
136static DROPDOWN_COUNTER: AtomicU64 = AtomicU64::new(0);
137
138const DDM_SCALE_FROM: f32 = 0.8;
139const DDM_VERTICAL_PADDING: f32 = 8.0;
140const DDM_ITEM_H_PAD: f32 = 12.0;
141const DDM_ITEM_MIN_HEIGHT: f32 = 48.0;
142const DDM_MIN_OPEN_HEIGHT: f32 = 48.0;
143
144/// Either a menu item or a divider.
145#[derive(Clone)]
146pub enum DropdownMenuEntry {
147    Item(DropdownMenuItem),
148    Divider,
149}
150
151/// M3 Dropdown Menu anchored to a trigger element.
152///
153/// Renders as a single overlay entry with a transparent full-screen scrim and
154/// positioned card, matching Compose's Popup behavior. The card is bounded in
155/// height so vertical_scroll activates when content overflows.
156pub fn DropdownMenu(
157    state: Rc<MenuState>,
158    overlay: OverlayHandle,
159    modifier: Modifier,
160    trigger: View,
161    items: Vec<DropdownMenuEntry>,
162    config: DropdownMenuConfig,
163) -> View {
164    let th = theme();
165    let ddm_id = remember(|| DROPDOWN_COUNTER.fetch_add(1, Ordering::Relaxed));
166    let overlay_id = remember_with_key(format!("ddm_oid_{ddm_id}"), || signal(0u64));
167    let trigger_rect = remember_state_with_key(format!("ddm_tr_{ddm_id}"), Rect::default);
168    let scroll_state: Rc<ScrollState> =
169        remember_with_key(format!("ddm_scroll_{ddm_id}"), ScrollState::new);
170
171    let trigger = Box(Modifier::new().on_globally_positioned({
172        let tr = trigger_rect.clone();
173        move |rect| {
174            *tr.borrow_mut() = rect;
175        }
176    }))
177    .child(trigger);
178
179    let anim = remember_state_with_key(format!("ddm_anim_{ddm_id}"), || {
180        AnimatedValue::new(0.0, theme().motion.overlay)
181    });
182    let last_target = remember_state_with_key(format!("ddm_lt_{ddm_id}"), || f32::NAN);
183    let anim_target = if state.is_open() { 1.0 } else { 0.0 };
184
185    {
186        let mut a = anim.borrow_mut();
187        let mut lt = last_target.borrow_mut();
188        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
189            a.set_target(anim_target);
190            *lt = anim_target;
191        }
192        drop(lt);
193        if a.update() {
194            request_frame();
195        }
196    }
197
198    let progress = *anim.borrow().get();
199    let menu_visible = state.is_open() || progress > 0.01;
200
201    if menu_visible {
202        if overlay_id.get() == 0 {
203            let anim = anim.clone();
204            let th = th.clone();
205            let items = items.clone();
206            let state = state.clone();
207            let config = config.clone();
208            let trigger_rect = trigger_rect.clone();
209            let scroll_state = scroll_state.clone();
210
211            let id = overlay.show_entry(
212                Rc::new(move || {
213                    let p = *anim.borrow().get();
214                    let scale = DDM_SCALE_FROM + (1.0 - DDM_SCALE_FROM) * p;
215                    let alpha = p;
216
217                    let rect = *trigger_rect.borrow();
218                    let win_h = get_window_container_height();
219                    let hm = config.vertical_margin;
220
221                    let space_below = (win_h - hm) - (rect.y + rect.h);
222                    let space_above = rect.y - hm;
223
224                    let estimated_h = estimate_dropdown_height(&items, &config)
225                        .min(space_below.max(space_above))
226                        .max(DDM_MIN_OPEN_HEIGHT);
227                    let place_below = space_below >= estimated_h
228                        || (space_above < estimated_h && space_below >= space_above);
229                    let available_height =
230                        (if place_below { space_below } else { space_above }).max(48.0);
231
232                    let popup_x = rect.x + config.offset_x;
233                    let constrained_width = config.max_width;
234
235                    let mut adjusted_config = config.clone();
236                    adjusted_config.max_width = constrained_width;
237
238                    let content = render_dropdown_menu_content(
239                        &th,
240                        &items,
241                        state.clone(),
242                        &adjusted_config,
243                        scroll_state.clone(),
244                        available_height,
245                    );
246
247                    let transform_origin_y = if place_below { 0.0 } else { 1.0 };
248
249                    let mut offset_modifier = Modifier::new();
250                    if place_below {
251                        offset_modifier = offset_modifier.offset(
252                            Some(popup_x),
253                            Some(rect.y + rect.h + config.offset_y),
254                            None,
255                            None,
256                        );
257                    } else {
258                        let menu_bottom_y = rect.y + config.offset_y;
259                        let offset_bottom = (win_h - menu_bottom_y).max(0.0);
260                        offset_modifier = offset_modifier
261                            .offset(Some(popup_x), None, None, Some(offset_bottom));
262                    }
263
264                    let menu = Box(
265                        offset_modifier
266                            .absolute()
267                            .scale(scale)
268                            .alpha(alpha)
269                            .transform_origin(0.0, transform_origin_y),
270                    )
271                    .child(content);
272
273                    let scrim = Box(Modifier::new().fill_max_size().on_pointer_down({
274                        let s = state.clone();
275                        move |_| s.dismiss()
276                    }));
277
278                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, menu))
279                }),
280                901.0,
281                false,
282            );
283            overlay_id.set(id);
284        }
285    } else {
286        let prev = overlay_id.get();
287        if prev != 0 {
288            let _ = overlay.dismiss(prev);
289            overlay_id.set(0);
290        }
291    }
292
293    Box(modifier).child(trigger)
294}
295
296fn estimate_dropdown_height(items: &[DropdownMenuEntry], config: &DropdownMenuConfig) -> f32 {
297    let mut h = 2.0 * DDM_VERTICAL_PADDING;
298    for entry in items {
299        match entry {
300            DropdownMenuEntry::Item(_) => {
301                h += config.item_height.max(DDM_ITEM_MIN_HEIGHT);
302            }
303            // Divider: 1px line + 12px horizontal margins (also vertical here).
304            DropdownMenuEntry::Divider => h += 1.0 + 2.0 * 12.0,
305        }
306    }
307    h
308}
309
310fn render_dropdown_menu_content(
311    th: &Theme,
312    items: &[DropdownMenuEntry],
313    state: Rc<MenuState>,
314    config: &DropdownMenuConfig,
315    scroll_state: Rc<ScrollState>,
316    max_height: f32,
317) -> View {
318    let children: Vec<View> = items
319        .iter()
320        .map(|entry| match entry {
321            DropdownMenuEntry::Item(item) => {
322                let text_color = if item.enabled {
323                    config.item_text_color
324                } else {
325                    config.disabled_item_text_color
326                };
327                let on_click = item.on_click.clone();
328                let state = state.clone();
329                let item_source: Rc<MutableInteractionSource> =
330                    remember(MutableInteractionSource::new);
331
332                let mut modifier = Modifier::new()
333                    .fill_max_width()
334                    .min_height(config.item_height.max(DDM_ITEM_MIN_HEIGHT))
335                    .padding_values(PaddingValues {
336                        left: DDM_ITEM_H_PAD,
337                        right: DDM_ITEM_H_PAD,
338                        top: 0.0,
339                        bottom: 0.0,
340                    })
341                    .align_items(AlignItems::CENTER);
342
343                if item.enabled {
344                    modifier = modifier
345                        .state_colors(StateColors {
346                            default: Color::TRANSPARENT,
347                            hovered: th.on_surface.with_alpha_f32(0.08),
348                            pressed: th.on_surface.with_alpha_f32(0.12),
349                            dragged: th.on_surface.with_alpha_f32(0.12),
350                            disabled: Color::TRANSPARENT,
351                        })
352                        .interaction_source(&*item_source)
353                        .clickable()
354                        .on_click(move || {
355                            on_click();
356                            state.dismiss();
357                        });
358                }
359
360                let mut row_children: Vec<View> = Vec::new();
361                if let Some(icon) = item.leading_icon.clone() {
362                    row_children.push(icon);
363                    row_children.push(Box(Modifier::new().width(DDM_ITEM_H_PAD)));
364                }
365                row_children.push(
366                    Box(Modifier::new().flex_grow(1.0)).child(
367                        Text(item.text.clone())
368                            .color(text_color)
369                            .size(th.typography.label_large)
370                            .single_line(),
371                    ),
372                );
373                if let Some(icon) = item.trailing_icon.clone() {
374                    row_children.push(Box(Modifier::new().width(DDM_ITEM_H_PAD)));
375                    row_children.push(icon);
376                }
377                Row(modifier).child(row_children)
378            }
379            DropdownMenuEntry::Divider => Box(Modifier::new()
380                .fill_max_width()
381                .height(1.0)
382                .margin(12.0)
383                .background(config.divider_color)),
384        })
385        .collect();
386
387    let binding = scroll_state.to_binding();
388    let axis_binding = match &binding {
389        ScrollBinding::Vertical(a) => a.clone(),
390        _ => unreachable!(),
391    };
392
393    let items_column = Box(
394        Modifier::new()
395            .fill_max_width()
396            .max_height((max_height - 2.0 * DDM_VERTICAL_PADDING).max(0.0))
397            .vertical_scroll(axis_binding),
398    )
399    .child(Column(Modifier::new().fill_max_width()).with_children(children));
400
401    let shadow_elevation = config
402        .shadow_elevation
403        .unwrap_or(th.elevation.level2);
404
405    let mut card_modifier = Modifier::new()
406        .shadow(shadow_elevation, 0.0)
407        .min_width(config.min_width)
408        .max_width(config.max_width)
409        .padding_values(PaddingValues {
410            left: 0.0,
411            right: 0.0,
412            top: DDM_VERTICAL_PADDING,
413            bottom: DDM_VERTICAL_PADDING,
414        })
415        .background(config.container_color)
416        .clip_rounded(config.shape_radius.unwrap_or(th.shapes.extra_small));
417
418    card_modifier = apply_tonal_elevation(card_modifier, config.tonal_elevation, config.container_color);
419
420    if let Some((border_width, border_color, border_radius)) = config.border {
421        card_modifier = card_modifier.border(border_width, border_color, border_radius);
422    }
423
424    Box(card_modifier).child(items_column)
425}