repose_material/material3/
dropdown_menu.rs1#![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#[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#[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
91pub struct MenuDivider;
93
94pub 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#[derive(Clone)]
142pub enum DropdownMenuEntry {
143 Item(DropdownMenuItem),
144 Divider,
145}
146
147pub 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 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}