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;
142
143/// Either a menu item or a divider.
144#[derive(Clone)]
145pub enum DropdownMenuEntry {
146    Item(DropdownMenuItem),
147    Divider,
148}
149
150/// M3 Dropdown Menu anchored to a trigger element.
151///
152/// Renders as a single overlay entry with a transparent full-screen scrim and
153/// positioned card, matching Compose's Popup behavior. The card is bounded in
154/// height so vertical_scroll activates when content overflows.
155pub fn DropdownMenu(
156    state: Rc<MenuState>,
157    overlay: OverlayHandle,
158    modifier: Modifier,
159    trigger: View,
160    items: Vec<DropdownMenuEntry>,
161    config: DropdownMenuConfig,
162) -> View {
163    let th = theme();
164    let ddm_id = remember(|| DROPDOWN_COUNTER.fetch_add(1, Ordering::Relaxed));
165    let overlay_id = remember_with_key(format!("ddm_oid_{ddm_id}"), || signal(0u64));
166    let trigger_rect = remember_state_with_key(format!("ddm_tr_{ddm_id}"), Rect::default);
167    let scroll_state: Rc<ScrollState> =
168        remember_with_key(format!("ddm_scroll_{ddm_id}"), ScrollState::new);
169
170    let trigger = Box(Modifier::new().on_globally_positioned({
171        let tr = trigger_rect.clone();
172        move |rect| {
173            *tr.borrow_mut() = rect;
174        }
175    }))
176    .child(trigger);
177
178    let anim = remember_state_with_key(format!("ddm_anim_{ddm_id}"), || {
179        AnimatedValue::new(0.0, theme().motion.overlay)
180    });
181    let last_target = remember_state_with_key(format!("ddm_lt_{ddm_id}"), || f32::NAN);
182    let anim_target = if state.is_open() { 1.0 } else { 0.0 };
183
184    {
185        let mut a = anim.borrow_mut();
186        let mut lt = last_target.borrow_mut();
187        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
188            a.set_target(anim_target);
189            *lt = anim_target;
190        }
191        drop(lt);
192        if a.update() {
193            request_frame();
194        }
195    }
196
197    let progress = *anim.borrow().get();
198    let menu_visible = state.is_open() || progress > 0.01;
199
200    if menu_visible {
201        if overlay_id.get() == 0 {
202            let anim = anim.clone();
203            let th = th.clone();
204            let items = items.clone();
205            let state = state.clone();
206            let config = config.clone();
207            let trigger_rect = trigger_rect.clone();
208            let scroll_state = scroll_state.clone();
209
210            let id = overlay.show_entry(
211                Rc::new(move || {
212                    let p = *anim.borrow().get();
213                    let scale = DDM_SCALE_FROM + (1.0 - DDM_SCALE_FROM) * p;
214                    let alpha = p;
215
216                    let rect = *trigger_rect.borrow();
217                    let win_h = get_window_container_height();
218                    let hm = config.vertical_margin;
219
220                    let space_below = (win_h - hm) - (rect.y + rect.h);
221                    let space_above = rect.y - hm;
222                    let place_below = space_below >= space_above;
223                    let available_height = (if place_below { space_below } else { space_above }).max(48.0);
224
225                    let popup_x = rect.x + config.offset_x;
226                    let constrained_width = config.max_width;
227
228                    let mut adjusted_config = config.clone();
229                    adjusted_config.max_width = constrained_width;
230
231                    let popup_y = if place_below {
232                        rect.y + rect.h + config.offset_y
233                    } else {
234                        // Anchor bottom, so stays in the space above instead
235                        // of growing down off-screen.
236                        (rect.y - config.offset_y - available_height).max(hm)
237                    };
238
239                    let content = render_dropdown_menu_content(
240                        &th,
241                        &items,
242                        state.clone(),
243                        &adjusted_config,
244                        scroll_state.clone(),
245                        available_height,
246                    );
247
248                    let transform_origin_y = if place_below { 0.0 } else { 1.0 };
249
250                    let menu = Box(
251                        Modifier::new()
252                            .absolute()
253                            .offset(Some(popup_x), Some(popup_y), None, None)
254                            .scale(scale)
255                            .alpha(alpha)
256                            .transform_origin(0.0, transform_origin_y),
257                    )
258                    .child(content);
259
260                    let scrim = Box(Modifier::new().fill_max_size().on_pointer_down({
261                        let s = state.clone();
262                        move |_| s.dismiss()
263                    }));
264
265                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, menu))
266                }),
267                901.0,
268                false,
269            );
270            overlay_id.set(id);
271        }
272    } else {
273        let prev = overlay_id.get();
274        if prev != 0 {
275            let _ = overlay.dismiss(prev);
276            overlay_id.set(0);
277        }
278    }
279
280    Box(modifier).child(trigger)
281}
282
283fn render_dropdown_menu_content(
284    th: &Theme,
285    items: &[DropdownMenuEntry],
286    state: Rc<MenuState>,
287    config: &DropdownMenuConfig,
288    scroll_state: Rc<ScrollState>,
289    max_height: f32,
290) -> View {
291    let children: Vec<View> = items
292        .iter()
293        .map(|entry| match entry {
294            DropdownMenuEntry::Item(item) => {
295                let text_color = if item.enabled {
296                    config.item_text_color
297                } else {
298                    config.disabled_item_text_color
299                };
300                let on_click = item.on_click.clone();
301                let state = state.clone();
302                let item_source: Rc<MutableInteractionSource> =
303                    remember(MutableInteractionSource::new);
304
305                let mut modifier = Modifier::new()
306                    .fill_max_width()
307                    .min_height(config.item_height.max(DDM_ITEM_MIN_HEIGHT))
308                    .padding_values(PaddingValues {
309                        left: DDM_ITEM_H_PAD,
310                        right: DDM_ITEM_H_PAD,
311                        top: 0.0,
312                        bottom: 0.0,
313                    })
314                    .align_items(AlignItems::CENTER);
315
316                if item.enabled {
317                    modifier = modifier
318                        .state_colors(StateColors {
319                            default: Color::TRANSPARENT,
320                            hovered: th.on_surface.with_alpha_f32(0.08),
321                            pressed: th.on_surface.with_alpha_f32(0.12),
322                            disabled: Color::TRANSPARENT,
323                        })
324                        .interaction_source(&*item_source)
325                        .clickable()
326                        .on_click(move || {
327                            on_click();
328                            state.dismiss();
329                        });
330                }
331
332                let mut row_children: Vec<View> = Vec::new();
333                if let Some(icon) = item.leading_icon.clone() {
334                    row_children.push(icon);
335                    row_children.push(Box(Modifier::new().width(DDM_ITEM_H_PAD)));
336                }
337                row_children.push(
338                    Box(Modifier::new().flex_grow(1.0)).child(
339                        Text(item.text.clone())
340                            .color(text_color)
341                            .size(th.typography.label_large)
342                            .single_line(),
343                    ),
344                );
345                if let Some(icon) = item.trailing_icon.clone() {
346                    row_children.push(Box(Modifier::new().width(DDM_ITEM_H_PAD)));
347                    row_children.push(icon);
348                }
349                Row(modifier).child(row_children)
350            }
351            DropdownMenuEntry::Divider => Box(Modifier::new()
352                .fill_max_width()
353                .height(1.0)
354                .margin(12.0)
355                .background(config.divider_color)),
356        })
357        .collect();
358
359    let binding = scroll_state.to_binding();
360    let axis_binding = match &binding {
361        ScrollBinding::Vertical(a) => a.clone(),
362        _ => unreachable!(),
363    };
364
365    let items_column = Box(
366        Modifier::new()
367            .fill_max_width()
368            .max_height((max_height - 2.0 * DDM_VERTICAL_PADDING).max(0.0))
369            .vertical_scroll(axis_binding),
370    )
371    .child(Column(Modifier::new().fill_max_width()).with_children(children));
372
373    let shadow_elevation = config
374        .shadow_elevation
375        .unwrap_or(th.elevation.level2);
376
377    let mut card_modifier = Modifier::new()
378        .shadow(shadow_elevation, 0.0)
379        .min_width(config.min_width)
380        .max_width(config.max_width)
381        .padding_values(PaddingValues {
382            left: 0.0,
383            right: 0.0,
384            top: DDM_VERTICAL_PADDING,
385            bottom: DDM_VERTICAL_PADDING,
386        })
387        .background(config.container_color)
388        .clip_rounded(config.shape_radius.unwrap_or(th.shapes.extra_small));
389
390    card_modifier = apply_tonal_elevation(card_modifier, config.tonal_elevation, config.container_color);
391
392    if let Some((border_width, border_color, border_radius)) = config.border {
393        card_modifier = card_modifier.border(border_width, border_color, border_radius);
394    }
395
396    Box(card_modifier).child(items_column)
397}