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