repose_material/material3/
dialog.rs1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use repose_core::*;
7use repose_ui::overlay::OverlayHandle;
8use repose_ui::{Box, Column, ViewExt, ZStack};
9use web_time::Duration;
10
11use super::AlertDialogDefaults;
12use super::{DatePicker, DatePickerConfig, DatePickerState};
13use super::{TimePicker, TimePickerConfig, TimePickerState};
14
15static DIALOG_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17pub struct DialogState {
19 visible: Signal<bool>,
20 id: u64,
21}
22
23impl Default for DialogState {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl DialogState {
30 pub fn new() -> Self {
31 Self {
32 visible: signal(false),
33 id: DIALOG_COUNTER.fetch_add(1, Ordering::Relaxed),
34 }
35 }
36
37 pub fn key(&self, suffix: &str) -> String {
38 format!("dlg_{}_{}", self.id, suffix)
39 }
40
41 pub fn is_visible(&self) -> bool {
42 self.visible.get()
43 }
44
45 pub fn show(&self) {
46 self.visible.set(true);
47 }
48
49 pub fn dismiss(&self) {
50 self.visible.set(false);
51 }
52}
53
54#[derive(Clone)]
57pub struct DialogProperties {
58 pub on_dismiss_request: Option<Rc<dyn Fn()>>,
62 pub dismiss_on_click_outside: bool,
65 pub dismiss_on_back_press: bool,
68 pub use_platform_default_width: bool,
70 pub use_platform_insets: bool,
72}
73
74impl Default for DialogProperties {
75 fn default() -> Self {
76 Self {
77 on_dismiss_request: None,
78 dismiss_on_click_outside: true,
79 dismiss_on_back_press: true,
80 use_platform_default_width: true,
81 use_platform_insets: true,
82 }
83 }
84}
85
86fn preferred_dialog_width_dp(container_w: f32, container_h: f32) -> f32 {
87 let smallest = container_w.min(container_h);
88 if smallest >= 600.0 {
89 super::DialogDefaults::PREFERRED_WIDTH_EXPANDED
90 } else if smallest >= 480.0 {
91 super::DialogDefaults::PREFERRED_WIDTH_MEDIUM
92 } else {
93 super::DialogDefaults::PREFERRED_WIDTH_COMPACT
94 }
95}
96
97fn dialog_available_bounds(
98 use_platform_default_width: bool,
99 use_platform_insets: bool,
100) -> (f32 , f32 , PaddingValues) {
101 let win_w = get_window_container_width().max(0.0);
102 let win_h = get_window_container_height().max(0.0);
103
104 let mut pad = PaddingValues::default();
105 if use_platform_insets {
106 let insets = window_insets();
107 pad.left = px_to_dp(insets.left);
109 pad.right = px_to_dp(insets.right);
110 pad.top = px_to_dp(insets.top);
111 pad.bottom = px_to_dp(insets.bottom) + px_to_dp(insets.ime_bottom);
112 }
113
114 let avail_w = (win_w - pad.left - pad.right).max(0.0);
115 let avail_h = (win_h - pad.top - pad.bottom).max(0.0);
116
117 let max_w = if use_platform_default_width {
118 preferred_dialog_width_dp(win_w, win_h)
119 .min(avail_w)
120 .min(super::DialogDefaults::MAX_WIDTH)
121 } else {
122 avail_w.min(super::DialogDefaults::MAX_WIDTH)
123 };
124 let max_h = avail_h;
126
127 (max_w.max(0.0), max_h.max(0.0), pad)
128}
129
130fn clamp_dialog_modifier(mut m: Modifier, platform_max_w: f32, platform_max_h: f32) -> Modifier {
132 let max_w = m
133 .max_width
134 .unwrap_or(platform_max_w)
135 .min(platform_max_w)
136 .max(0.0);
137 let max_h = m
138 .max_height
139 .unwrap_or(platform_max_h)
140 .min(platform_max_h)
141 .max(0.0);
142 m.max_width = Some(max_w);
143 m.max_height = Some(max_h);
144
145 if let Some(min_w) = m.min_width {
147 m.min_width = Some(min_w.min(max_w).max(0.0));
148 } else {
149 m.min_width = Some(super::DialogDefaults::MIN_WIDTH.min(max_w).max(0.0));
150 }
151 if let Some(min_h) = m.min_height {
152 m.min_height = Some(min_h.min(max_h).max(0.0));
153 }
154 m
155}
156
157pub fn Dialog(
172 state: Rc<DialogState>,
173 overlay: OverlayHandle,
174 modifier: Modifier,
175 properties: DialogProperties,
176 content: View,
177) -> View {
178 let overlay_id = remember_with_key(state.key("oid"), || signal(0u64));
179
180 let current_content = remember_state_with_key(state.key("c"), || Box(Modifier::new()));
182 *current_content.borrow_mut() = content;
183
184 let props = remember_state_with_key(state.key("p"), || properties.clone());
186 *props.borrow_mut() = properties;
187
188 let scroll_state: Rc<repose_core::scroll::ScrollState> =
189 remember_with_key(state.key("scroll"), repose_core::scroll::ScrollState::new);
190
191 let spec = AnimationSpec::tween(Duration::from_millis(200), Easing::FastOutSlowIn);
193 let anim = remember_state_with_key(state.key("anim"), || AnimatedValue::new(0.0, spec));
194 let last_target = remember_state_with_key(state.key("atarget"), || f32::NAN);
195 let anim_target = if state.is_visible() { 1.0 } else { 0.0 };
196
197 {
198 let mut a = anim.borrow_mut();
199 let mut lt = last_target.borrow_mut();
200 if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
201 a.set_spec(spec);
202 a.set_target(anim_target);
203 *lt = anim_target;
204 }
205 drop(lt);
206 if a.update() {
207 request_frame();
208 }
209 }
210
211 let progress = *anim.borrow().get();
212 let visible = state.is_visible() || progress > 0.01;
213
214 if visible {
215 if overlay_id.get() == 0 {
216 let builder: Rc<dyn Fn() -> View> = Rc::new({
217 let state = state.clone();
218 let anim = anim.clone();
219 let modifier = modifier.clone();
220 let current_content = current_content.clone();
221 let props = props.clone();
222 let scroll_state = scroll_state.clone();
223 move || {
224 let progress = *anim.borrow().get();
225 let alpha = progress.min(1.0);
226 let scale = 0.8 + 0.2 * progress;
227 let th = theme();
228 let content = current_content.borrow().clone();
229 let p = props.borrow().clone();
230
231 let (platform_max_w, platform_max_h, inset_pad) = dialog_available_bounds(
233 p.use_platform_default_width,
234 p.use_platform_insets,
235 );
236
237 let dialog_mod = clamp_dialog_modifier(
238 Modifier::new()
239 .min_width(super::DialogDefaults::MIN_WIDTH)
240 .max_width(super::DialogDefaults::MAX_WIDTH)
241 .then(modifier.clone())
242 .justify_content(JustifyContent::CENTER)
243 .background(th.surface_container_high)
244 .clip_rounded(th.shapes.extra_large)
245 .alpha(alpha)
246 .scale(scale)
247 .focus_group()
248 .clickable()
249 .focusable(false)
250 .on_key_event({
251 let s = state.clone();
252 let props = props.clone();
253 move |ke| {
254 use repose_core::input::{Key, KeyEventType};
255 if ke.key == Key::Escape && ke.event_type == KeyEventType::Down
256 {
257 let (dismiss, cb) = {
258 let p = props.borrow();
259 (p.dismiss_on_back_press, p.on_dismiss_request.clone())
260 };
261 if dismiss {
262 if let Some(cb) = cb {
263 cb();
264 } else {
265 s.dismiss();
266 }
267 return true;
268 }
269 }
270 false
271 }
272 }),
273 platform_max_w,
274 platform_max_h,
275 );
276
277 let axis_binding = match scroll_state.to_binding() {
279 repose_core::scroll::ScrollBinding::Vertical(a) => a,
280 _ => unreachable!(),
281 };
282 let scrollable_body = Box(Modifier::new()
283 .fill_max_width()
284 .max_height(platform_max_h)
285 .vertical_scroll(axis_binding))
286 .child(content);
287
288 let dialog = Box(dialog_mod).child(scrollable_body);
289
290 let scrim_color = AlertDialogDefaults::scrim_color();
291 let scrim_alpha = (scrim_color.3 as f32 / 255.0) * alpha;
292 let scrim = Box(Modifier::new()
293 .fill_max_size()
294 .background(scrim_color.with_alpha_f32(scrim_alpha.clamp(0.0, 1.0)))
295 .focusable(false)
296 .input_blocker()
297 .on_scroll(|_| Vec2::default())
298 .on_click({
299 let s = state.clone();
300 let props = props.clone();
301 move || {
302 let (dismiss, cb) = {
303 let p = props.borrow();
304 (p.dismiss_on_click_outside, p.on_dismiss_request.clone())
305 };
306 if dismiss {
307 if let Some(cb) = cb {
308 cb();
309 } else {
310 s.dismiss();
311 }
312 }
313 }
314 }));
315
316 ZStack(Modifier::new().fill_max_size().absolute()).child((
318 scrim,
319 Box(Modifier::new()
320 .fill_max_size()
321 .padding_values(inset_pad)
322 .justify_content(JustifyContent::CENTER)
323 .align_items(AlignItems::CENTER)
324 .hit_passthrough())
325 .child(dialog),
326 ))
327 }
328 });
329
330 let id = overlay.show_entry(builder, 900.0, false);
331 overlay_id.set(id);
332 }
333 } else {
334 let prev = overlay_id.get();
335 if prev != 0 {
336 let _ = overlay.dismiss(prev);
337 overlay_id.set(0);
338 }
339 }
340
341 Box(Modifier::new())
342}
343
344#[derive(Clone, Debug)]
346pub struct AlertDialogConfig {
347 pub modifier: Modifier,
348 pub scrim_color: Color,
349 pub min_width: f32,
350 pub max_width: f32,
351 pub horizontal_padding: f32,
352 pub shape_radius: Option<f32>,
353 pub container_color: Color,
354 pub tonal_elevation: f32,
355}
356
357impl Default for AlertDialogConfig {
358 fn default() -> Self {
359 Self {
360 modifier: Modifier::new(),
361 scrim_color: AlertDialogDefaults::scrim_color(),
362 min_width: AlertDialogDefaults::MIN_WIDTH,
363 max_width: AlertDialogDefaults::MAX_WIDTH,
364 horizontal_padding: AlertDialogDefaults::HORIZONTAL_PADDING,
365 shape_radius: None,
366 container_color: theme().surface_container_high,
367 tonal_elevation: 0.0,
368 }
369 }
370}
371
372pub fn AlertDialog(
377 state: Rc<DialogState>,
378 overlay: OverlayHandle,
379 title: View,
380 text: View,
381 confirm_button: View,
382 dismiss_button: Option<View>,
383 config: AlertDialogConfig,
384) -> View {
385 let content = Box(Modifier::new()
386 .background(config.container_color)
387 .clip_rounded(
388 config
389 .shape_radius
390 .unwrap_or_else(|| theme().shapes.extra_large),
391 ))
392 .child(super::alert_dialog_body(
393 title,
394 text,
395 confirm_button,
396 dismiss_button,
397 ));
398
399 Dialog(
400 state,
401 overlay,
402 Modifier::new()
403 .min_width(config.min_width)
404 .max_width(config.max_width)
405 .then(config.modifier),
406 DialogProperties::default(),
407 content,
408 )
409}
410
411#[derive(Clone)]
413pub struct DatePickerDialogConfig {
414 pub modifier: Modifier,
415 pub shape_radius: Option<f32>,
416 pub colors: super::DatePickerColors,
417}
418
419impl Default for DatePickerDialogConfig {
420 fn default() -> Self {
421 Self {
422 modifier: Modifier::new(),
423 shape_radius: None,
424 colors: super::DatePickerColors::default(),
425 }
426 }
427}
428
429pub fn DatePickerDialog(
435 state: Rc<DialogState>,
436 overlay: OverlayHandle,
437 picker_state: Rc<DatePickerState>,
438 on_confirm: Rc<dyn Fn(i32, u32, u32)>,
439 on_dismiss: Rc<dyn Fn()>,
440 config: DatePickerDialogConfig,
441) -> View {
442 let content = Box(Modifier::new()
443 .background(config.colors.container_color)
444 .clip_rounded(
445 config
446 .shape_radius
447 .unwrap_or_else(|| theme().shapes.extra_large),
448 ))
449 .child(Column(Modifier::new()).child((DatePicker(
450 picker_state.clone(),
451 on_confirm,
452 on_dismiss,
453 DatePickerConfig {
454 colors: config.colors,
455 ..DatePickerConfig::default()
456 },
457 ),)));
458
459 Dialog(
460 state,
461 overlay,
462 config.modifier,
463 DialogProperties::default(),
464 content,
465 )
466}
467
468#[derive(Clone)]
470pub struct TimePickerDialogConfig {
471 pub modifier: Modifier,
472 pub shape_radius: Option<f32>,
473 pub container_color: Color,
474 pub colors: super::TimePickerColors,
475}
476
477impl Default for TimePickerDialogConfig {
478 fn default() -> Self {
479 Self {
480 modifier: Modifier::new(),
481 shape_radius: None,
482 container_color: theme().surface_container_high,
483 colors: super::TimePickerColors::default(),
484 }
485 }
486}
487
488pub fn TimePickerDialog(
494 state: Rc<DialogState>,
495 overlay: OverlayHandle,
496 picker_state: Rc<TimePickerState>,
497 on_confirm: Rc<dyn Fn(u32, u32)>,
498 on_dismiss: Rc<dyn Fn()>,
499 config: TimePickerDialogConfig,
500) -> View {
501 let content = Box(Modifier::new()
502 .background(config.container_color)
503 .clip_rounded(
504 config
505 .shape_radius
506 .unwrap_or_else(|| theme().shapes.extra_large),
507 ))
508 .child(Column(Modifier::new()).child((TimePicker(
509 picker_state.clone(),
510 on_confirm,
511 on_dismiss,
512 TimePickerConfig {
513 colors: config.colors,
514 ..TimePickerConfig::default()
515 },
516 ),)));
517
518 Dialog(
519 state,
520 overlay,
521 config.modifier,
522 DialogProperties::default(),
523 content,
524 )
525}