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;
142
143#[derive(Clone)]
145pub enum DropdownMenuEntry {
146 Item(DropdownMenuItem),
147 Divider,
148}
149
150pub 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 (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}