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::{
8 Box, Column, Row, Text, TextStyle,
9 ViewExt, ZStack,
10 overlay::OverlayHandle,
11};
12
13use super::*;
14use super::util::apply_tonal_elevation;
15
16#[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#[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
95pub struct MenuDivider;
97
98pub 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#[derive(Clone)]
146pub enum DropdownMenuEntry {
147 Item(DropdownMenuItem),
148 Divider,
149}
150
151pub 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 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}