Skip to main content

cranpose_ui/widgets/
dialog.rs

1//! Modal dialogs.
2//!
3//! A dialog is the one surface that takes the whole screen's attention: while
4//! it is open, everything behind it is inert, a back gesture closes it rather
5//! than the screen behind it, a screen reader announces it and stays inside it,
6//! and a text field inside it keeps its focus while everything outside loses
7//! the ability to take focus away.
8//!
9//! Dialogs are built on [`crate::widgets::popup`], so their content escapes
10//! every ancestor clip and draws above the whole application, and they are
11//! centred inside the window insets so the system bars and the on-screen
12//! keyboard never cover them.
13
14use std::rc::Rc;
15
16use cranpose_ui_graphics::{Brush, Color, Point, Rect, Size};
17use cranpose_ui_layout::Alignment;
18
19use super::{
20    box_widget::{Box, BoxSpec},
21    popup::{PopupDismissable, local_popup_viewport},
22};
23use crate::{Modifier, SemanticsWidgetRole, composable, safe_area::window_insets};
24
25/// Why a dialog is being asked to close.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum DismissReason {
28    /// The user tapped outside the dialog's surface.
29    OutsideTap,
30    /// The user made the platform's back gesture, or pressed Escape.
31    BackRequested,
32}
33
34/// How a dialog behaves.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct DialogSpec {
37    /// Whether a tap outside the dialog dismisses it.
38    pub dismiss_on_outside_tap: bool,
39    /// Whether the platform's back gesture dismisses it.
40    pub dismiss_on_back: bool,
41    /// Whether the dialog keeps clear of the system bars and the on-screen
42    /// keyboard. A dialog that draws its own full-bleed surface turns this off.
43    pub apply_window_insets: bool,
44    /// Where the dialog's surface sits in the window: the centre, or the
45    /// bottom edge for a sheet.
46    pub alignment: Alignment,
47}
48
49impl Default for DialogSpec {
50    fn default() -> Self {
51        Self {
52            dismiss_on_outside_tap: true,
53            dismiss_on_back: true,
54            apply_window_insets: true,
55            alignment: Alignment::CENTER,
56        }
57    }
58}
59
60impl DialogSpec {
61    /// A dialog the user must answer: neither an outside tap nor a back
62    /// gesture closes it, so only the dialog's own controls can.
63    pub const fn required() -> Self {
64        Self {
65            dismiss_on_outside_tap: false,
66            dismiss_on_back: false,
67            apply_window_insets: true,
68            alignment: Alignment::CENTER,
69        }
70    }
71
72    /// Sets whether an outside tap dismisses the dialog.
73    pub const fn with_dismiss_on_outside_tap(mut self, dismiss: bool) -> Self {
74        self.dismiss_on_outside_tap = dismiss;
75        self
76    }
77
78    /// Sets whether the back gesture dismisses the dialog.
79    pub const fn with_dismiss_on_back(mut self, dismiss: bool) -> Self {
80        self.dismiss_on_back = dismiss;
81        self
82    }
83
84    /// Sets whether the dialog keeps clear of system insets.
85    pub const fn with_window_insets(mut self, apply: bool) -> Self {
86        self.apply_window_insets = apply;
87        self
88    }
89
90    /// Sets where the dialog's surface sits in the window.
91    pub const fn with_alignment(mut self, alignment: Alignment) -> Self {
92        self.alignment = alignment;
93        self
94    }
95}
96
97/// The default dim behind a dialog, matching what every platform's own modal
98/// surfaces use closely enough that a dialog does not look foreign.
99pub const DEFAULT_SCRIM: Color = Color(0.0, 0.0, 0.0, 0.4);
100
101/// The rectangle a dialog's surface is placed in.
102///
103/// The dialog fills the window minus its insets; the surface itself is centred
104/// inside that and sized by its own content.
105pub(crate) fn dialog_bounds(viewport: Size, insets: cranpose_ui_graphics::EdgeInsets) -> Rect {
106    let width = (viewport.width - insets.left - insets.right).max(0.0);
107    let height = (viewport.height - insets.top - insets.bottom).max(0.0);
108    Rect::from_origin_size(
109        Point {
110            x: insets.left,
111            y: insets.top,
112        },
113        Size { width, height },
114    )
115}
116
117/// A modal dialog holding `content`, dismissed through `on_dismiss`.
118///
119/// `on_dismiss` receives why the dialog is closing, so an application can treat
120/// "tapped away" differently from "went back" — a draft, for example, is
121/// usually kept in one case and discarded in the other.
122///
123/// ```rust,no_run
124/// # use cranpose_ui::widgets::{Dialog, DialogSpec};
125/// # use cranpose_ui::Modifier;
126/// # fn Content() {}
127/// # let open = true;
128/// if open {
129///     Dialog(
130///         DialogSpec::default(),
131///         |_reason| { /* close it */ },
132///         || {
133///             Content();
134///         },
135///     );
136/// }
137/// ```
138#[composable]
139pub fn Dialog<C>(spec: DialogSpec, on_dismiss: impl Fn(DismissReason) + 'static, content: C)
140where
141    C: Fn() + 'static,
142{
143    DialogWithScrim(spec, DEFAULT_SCRIM, on_dismiss, content);
144}
145
146/// A modal dialog whose scrim colour is chosen by the caller.
147#[composable]
148pub fn DialogWithScrim<C>(
149    spec: DialogSpec,
150    scrim: Color,
151    on_dismiss: impl Fn(DismissReason) + 'static,
152    content: C,
153) where
154    C: Fn() + 'static,
155{
156    DialogWithFadingScrim(spec, scrim, || 1.0, on_dismiss, content);
157}
158
159/// A modal dialog whose scrim shows `scrim` at `scrim_alpha()` of its
160/// alpha. `scrim_alpha` is read while the scrim draws, so a scrim that fades
161/// with an animation, as a sheet's does, redraws without recomposing.
162#[composable]
163pub fn DialogWithFadingScrim<C>(
164    spec: DialogSpec,
165    scrim: Color,
166    scrim_alpha: impl Fn() -> f32 + 'static,
167    on_dismiss: impl Fn(DismissReason) + 'static,
168    content: C,
169) where
170    C: Fn() + 'static,
171{
172    let own_modal_depth =
173        cranpose_core::remember(|| crate::modal::current_modal_depth() + 1).with(|depth| *depth);
174
175    let viewport = local_popup_viewport().current().get();
176    let insets = if spec.apply_window_insets {
177        window_insets().combined()
178    } else {
179        cranpose_ui_graphics::EdgeInsets::default()
180    };
181    let bounds = dialog_bounds(viewport, insets);
182
183    let on_dismiss = Rc::new(on_dismiss);
184    let content = Rc::new(content);
185
186    let dismiss_on_outside_tap = spec.dismiss_on_outside_tap;
187    let scrim_dismiss = {
188        let on_dismiss = Rc::clone(&on_dismiss);
189        move || {
190            if dismiss_on_outside_tap {
191                on_dismiss(DismissReason::OutsideTap);
192            }
193        }
194    };
195
196    let dismiss_on_back = spec.dismiss_on_back;
197    let back = {
198        let on_dismiss = Rc::clone(&on_dismiss);
199        cranpose_core::rememberUpdatedState::<Rc<dyn Fn()>>(Rc::new(move || {
200            if dismiss_on_back {
201                on_dismiss(DismissReason::BackRequested);
202            }
203        }))
204    };
205    cranpose_core::DisposableEffect((), move |scope| {
206        let registration = crate::modal::register_modal(Rc::new(move || (back.value())()));
207        scope.on_dispose(move || drop(registration))
208    });
209
210    let scrim_alpha = Rc::new(scrim_alpha);
211    PopupDismissable(bounds, Point { x: 0.0, y: 0.0 }, scrim_dismiss, move || {
212        let content = Rc::clone(&content);
213        let scrim_alpha = Rc::clone(&scrim_alpha);
214        Box(
215            Modifier::empty()
216                .size(Size {
217                    width: bounds.width,
218                    height: bounds.height,
219                })
220                .draw_behind(move |scope| {
221                    let alpha = scrim.3 * scrim_alpha().clamp(0.0, 1.0);
222                    scope.draw_rect(Brush::solid(Color(scrim.0, scrim.1, scrim.2, alpha)));
223                })
224                .focus_target()
225                .stable_semantics(move |config| {
226                    config.role = Some(SemanticsWidgetRole::Dialog);
227                    config.is_modal = true;
228                    // A reader stays inside the dialog, so the dialog itself
229                    // offers the way out that the back gesture takes.
230                    config.dismiss = dismiss_on_back.then(|| {
231                        cranpose_foundation::SemanticsDismiss::new(move || {
232                            (back.value())();
233                            true
234                        })
235                    });
236                }),
237            BoxSpec::default().content_alignment(spec.alignment),
238            move || {
239                let content = Rc::clone(&content);
240                // Taps on the dialog's own surface stay off the scrim below.
241                Box(
242                    Modifier::empty().pointer_opaque(),
243                    BoxSpec::default(),
244                    move || {
245                        let content = Rc::clone(&content);
246                        cranpose_core::CompositionLocalProvider(
247                            [crate::modal::local_modal_depth().provides(own_modal_depth)],
248                            move || content(),
249                        );
250                    },
251                );
252            },
253        );
254    });
255}
256
257#[cfg(test)]
258#[path = "tests/dialog_tests.rs"]
259mod tests;