Skip to main content

frus_widgets/
bottomsheet.rs

1//! [`BottomSheet`]: a **modal sheet** that slides up from the bottom of the
2//! window — for a set of contextual actions or a short form, without leaving
3//! the current screen.
4//!
5//! The body stays visible behind; when the sheet is open, a full-width panel
6//! rises from the bottom edge over it, with a scrim that closes it on click.
7//! The slide is animated automatically (a spring curve, like the drawer —
8//! milestones 46/48), with no wiring on the application side.
9//!
10//! ```ignore
11//! BottomSheet::new(app.sheet_open)
12//!     .on_dismiss(Msg::CloseSheet)
13//!     .sheet(actions_column)  // the sheet's content
14//!     .body(main_screen)      // the background content (always visible)
15//! ```
16
17use frus_core::{Color, Insets, Rect, Scene, ShapeBorder};
18use frus_layout::{Dimension, FlexDirection, Style};
19
20use crate::interaction::Status;
21use crate::portal::Placement;
22use crate::theme::Theme;
23use crate::widget::Widget;
24
25/// The grabber at the top of the sheet: width and height in logical pixels.
26const GRABBER_WIDTH: f32 = 36.0;
27const GRABBER_HEIGHT: f32 = 4.0;
28
29/// The sheet's inner panel: full width, natural height, a themed background,
30/// a hairline and a grabber at the top.
31struct SheetPanel<Msg> {
32    children: Vec<Box<dyn Widget<Msg>>>,
33    /// The caller's surface colour, if one was named.
34    background: Option<Color>,
35    /// The caller's shape, if one was named.
36    shape: Option<ShapeBorder>,
37}
38
39impl<Msg> SheetPanel<Msg> {
40    /// **What shape the sheet is**: the caller's word, then the theme's shape, then the
41    /// theme's plain radius on the **top** two corners, then the framework's own.
42    ///
43    /// The bottom edge is flush against the window: rounding it would cut two notches out
44    /// of the screen, so the framework's default rounds the top pair and nothing else.
45    fn shape_of(&self, theme: &Theme) -> ShapeBorder {
46        crate::resolve_shape(
47            self.shape,
48            theme.widgets.bottom_sheet.shape,
49            theme
50                .widgets
51                .bottom_sheet
52                .radius
53                .map(frus_core::BorderRadius::top),
54            ShapeBorder::rounded(frus_core::BorderRadius::top(theme.radius + 6.0)),
55        )
56    }
57}
58
59impl<Msg: Clone> Widget<Msg> for SheetPanel<Msg> {
60    /// It asks to **fill the width it is offered** rather than declaring one — see
61    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
62    /// width, which a parent that shrink-wraps does not have yet.
63    fn style(&self) -> Style {
64        Style {
65            // Natural height: the content sets the height and the sheet adjusts to it.
66            height: Dimension::Auto,
67            flex_direction: FlexDirection::Column,
68            // Top padding to let the grabber breathe above the content.
69            padding: Insets::new(20.0, 0.0, 0.0, 0.0),
70            ..Default::default()
71        }
72    }
73
74    /// The width it was **offered**, not the width its parent came out at.
75    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
76        crate::widget::FillAxes::WIDTH
77    }
78
79    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
80        &self.children
81    }
82
83    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
84        let o = status.opacity;
85        // An opaque surface with rounded **top** corners (the bottom edge is flush
86        // with the window) + a thin top hairline, inset from the rounding.
87        let shape = self.shape_of(theme);
88        // The corners that shape resolves to — what the fill takes, and what the hairline
89        // below is inset by so it does not cross a curve.
90        let radius = shape
91            .as_rounded(bounds)
92            .map(|(_, r)| r)
93            .unwrap_or(frus_core::BorderRadius::ZERO);
94        let inset = radius.top_left.max(radius.top_right);
95        // `bottom_sheet.dart:1496` — a sheet rises off the page, on the low rung.
96        let fill = self
97            .background
98            .or(theme.widgets.bottom_sheet.background_color)
99            .unwrap_or(theme.scheme.surface_container_low);
100        scene.draw_shape(bounds, shape, fill.fade(o));
101        scene.fill_rect(
102            Rect::new(
103                bounds.x + inset,
104                bounds.y,
105                (bounds.width - 2.0 * inset).max(0.0),
106                1.0,
107            ),
108            theme.scheme.outline_variant.fade(o),
109        );
110        // A rounded grabber, centred near the top.
111        let gx = bounds.x + (bounds.width - GRABBER_WIDTH) * 0.5;
112        let gy = bounds.y + 8.0;
113        scene.draw_rect(
114            Rect::new(gx, gy, GRABBER_WIDTH, GRABBER_HEIGHT),
115            theme.muted.fade(0.5 * o),
116            GRABBER_HEIGHT * 0.5,
117            0.0,
118            Color::TRANSPARENT,
119        );
120    }
121
122    fn on_click(&self) -> Option<Msg> {
123        None
124    }
125}
126
127/// A modal sheet sliding up from the bottom: a background body + a retractable panel.
128pub struct BottomSheet<Msg = crate::callback::Callback> {
129    open: bool,
130    on_dismiss: Option<Msg>,
131    /// The sheet's content, supplied by the caller (before `SheetPanel` wrapping).
132    sheet_content: Option<Box<dyn Widget<Msg>>>,
133    /// The wrapped modal panel, ready for the overlay.
134    modal_panel: Option<Box<dyn Widget<Msg>>>,
135    /// The sheet's surface, over the theme's and the framework's.
136    background: Option<Color>,
137    /// The sheet's shape, over the theme's and the framework's.
138    shape: Option<ShapeBorder>,
139    /// Children in the flow: `[body]` (the panel floats as an overlay).
140    children: Vec<Box<dyn Widget<Msg>>>,
141}
142
143impl<Msg: Clone + 'static> BottomSheet<Msg> {
144    /// Creates a sheet; `open` says whether it is expanded.
145    pub fn new(open: bool) -> Self {
146        Self {
147            open,
148            on_dismiss: None,
149            sheet_content: None,
150            modal_panel: None,
151            background: None,
152            shape: None,
153            children: Vec::new(),
154        }
155    }
156
157    /// The sheet's surface. Unset, the theme's, then the scheme's
158    /// `surface_container_low`.
159    #[must_use]
160    pub fn background(mut self, color: Color) -> Self {
161        self.background = Some(color);
162        self
163    }
164
165    /// **What shape the sheet is** — the reference's `shape`
166    /// (`BottomSheetThemeData.shape`), over the theme's.
167    ///
168    /// A sheet's bottom edge is flush against the window, so the framework rounds the top
169    /// pair only. A caller naming a shape has taken that decision on: a
170    /// `ShapeBorder::rounded(28.0)` rounds all four, and two of them are off-screen.
171    #[must_use]
172    pub fn shape(mut self, shape: ShapeBorder) -> Self {
173        self.shape = Some(shape);
174        self
175    }
176
177    /// Message emitted on a click on the scrim (outside the sheet) — to close it.
178    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
179        self.on_dismiss = Some(message.into());
180        self
181    }
182
183    /// Sets the **sheet's content** (actions, a short form…).
184    pub fn sheet(mut self, content: impl Widget<Msg> + 'static) -> Self {
185        self.sheet_content = Some(Box::new(content));
186        self
187    }
188
189    /// Sets the **background body** (always visible) and finalises the sheet.
190    pub fn body(mut self, body: impl Widget<Msg> + 'static) -> Self {
191        let background = self.background;
192        let shape = self.shape;
193        self.modal_panel = self.sheet_content.take().map(|content| {
194            Box::new(SheetPanel {
195                children: vec![content],
196                background,
197                shape,
198            }) as Box<dyn Widget<Msg>>
199        });
200        self.children = vec![Box::new(body)];
201        self
202    }
203}
204
205impl<Msg: Clone> Widget<Msg> for BottomSheet<Msg> {
206    /// It asks to **fill the width it is offered** rather than declaring one — see
207    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
208    /// width, which a parent that shrink-wraps does not have yet.
209    fn style(&self) -> Style {
210        Style {
211            height: Dimension::Percent(1.0),
212            flex_direction: FlexDirection::Column,
213            ..Default::default()
214        }
215    }
216
217    /// The width it was **offered**, not the width its parent came out at.
218    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
219        crate::widget::FillAxes::WIDTH
220    }
221
222    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
223        &self.children
224    }
225
226    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
227
228    fn on_click(&self) -> Option<Msg> {
229        None
230    }
231
232    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
233        // It is the animated **progress** (`anim_target`) that decides both the
234        // display and the upward slide.
235        self.modal_panel
236            .as_ref()
237            .map(|p| (p.as_ref(), Placement::Bottom))
238    }
239
240    fn overlay_dismiss(&self) -> Option<Msg> {
241        self.on_dismiss.clone()
242    }
243
244    fn anim_target(&self) -> Option<f32> {
245        // The `0↔1` open target, interpolated by the runtime (slide + fade).
246        Some(if self.open { 1.0 } else { 0.0 })
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    /// **A sheet takes a shape**, which it could not: its corner was `theme.radius + 6.0`,
253    /// an expression nobody chose, with no way for a caller or a theme to say otherwise.
254    ///
255    /// The framework's own default still rounds the **top** pair only — the bottom edge is
256    /// flush against the window, and rounding it would cut two notches out of the screen —
257    /// so the pixels of a sheet that says nothing are what they were.
258    #[test]
259    fn a_sheet_takes_a_shape() {
260        let corners = |sheet: BottomSheet<Msg>, theme: &Theme| {
261            let ui = build_ui(&sheet, Size::new(400.0, 600.0), &Runtime::default(), theme);
262            fn find(primitives: &[frus_core::Primitive]) -> Option<frus_core::BorderRadius> {
263                for p in primitives {
264                    match p {
265                        frus_core::Primitive::Rect { rect, radius, .. }
266                            if rect.width >= 399.0 && *radius != frus_core::BorderRadius::ZERO =>
267                        {
268                            return Some(*radius)
269                        }
270                        frus_core::Primitive::Layer { primitives, .. } => {
271                            if let Some(found) = find(primitives) {
272                                return Some(found);
273                            }
274                        }
275                        _ => {}
276                    }
277                }
278                None
279            }
280            find(ui.scene().primitives())
281        };
282
283        let theme = Theme::default();
284        // `body` is what wraps the panel, so anything the panel reads has to be said
285        // **before** it — the same ordering `background` has always had.
286        let open = |shape: Option<frus_core::ShapeBorder>| {
287            let mut sheet =
288                BottomSheet::<Msg>::new(true).sheet(Container::<Msg>::new().height(120.0));
289            if let Some(shape) = shape {
290                sheet = sheet.shape(shape);
291            }
292            sheet.body(Container::<Msg>::new())
293        };
294        assert_eq!(
295            corners(open(None), &theme),
296            Some(frus_core::BorderRadius::top(theme.radius + 6.0)),
297            "the framework's own, unchanged"
298        );
299        assert_eq!(
300            corners(
301                open(Some(frus_core::ShapeBorder::rounded(
302                    frus_core::BorderRadius::top(28.0),
303                ))),
304                &theme
305            ),
306            Some(frus_core::BorderRadius::top(28.0)),
307            "the caller's"
308        );
309
310        let mut themed = Theme::default();
311        themed.widgets.bottom_sheet.radius = Some(20.0);
312        assert_eq!(
313            corners(open(None), &themed),
314            Some(frus_core::BorderRadius::top(20.0)),
315            "a theme's plain radius, on the top pair"
316        );
317        themed.widgets.bottom_sheet.shape = Some(frus_core::ShapeBorder::rounded(
318            frus_core::BorderRadius::top(4.0),
319        ));
320        assert_eq!(
321            corners(open(None), &themed),
322            Some(frus_core::BorderRadius::top(4.0)),
323            "and a theme's shape outranks its radius"
324        );
325    }
326    use super::*;
327    use crate::{build_ui, Container, Runtime, Size, Text};
328
329    #[derive(Clone, Debug, PartialEq)]
330    enum Msg {
331        Close,
332    }
333
334    #[test]
335    fn anim_target_reflects_open_state() {
336        let closed = BottomSheet::new(false)
337            .on_dismiss(Msg::Close)
338            .sheet(Text::new("actions"))
339            .body(Container::<Msg>::new());
340        assert_eq!(Widget::<Msg>::anim_target(&closed), Some(0.0));
341        assert!(Widget::<Msg>::overlay(&closed).is_some());
342
343        let open = BottomSheet::new(true)
344            .on_dismiss(Msg::Close)
345            .sheet(Text::new("actions"))
346            .body(Container::<Msg>::new());
347        assert_eq!(Widget::<Msg>::anim_target(&open), Some(1.0));
348        assert_eq!(Widget::<Msg>::overlay_dismiss(&open), Some(Msg::Close));
349    }
350
351    #[test]
352    fn sheet_uses_bottom_placement() {
353        let s = BottomSheet::new(true)
354            .sheet(Text::new("actions"))
355            .body(Container::<Msg>::new());
356        assert!(matches!(
357            Widget::<Msg>::overlay(&s),
358            Some((_, Placement::Bottom))
359        ));
360    }
361
362    #[test]
363    fn closed_sheet_draws_no_scrim() {
364        let sheet = BottomSheet::new(false)
365            .on_dismiss(Msg::Close)
366            .sheet(Text::new("actions"))
367            .body(Container::<Msg>::new());
368        let ui = build_ui(
369            &sheet,
370            Size::new(500.0, 400.0),
371            &Runtime::default(),
372            &Theme::default(),
373        );
374        let scrim = ui.scene().primitives().iter().any(
375            |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 500.0 && rect.height >= 400.0),
376        );
377        assert!(!scrim, "a closed sheet paints no scrim");
378    }
379
380    #[test]
381    fn open_sheet_draws_scrim_and_full_width_panel() {
382        // Fixed-height content, so the sheet has a measurable height.
383        let sheet = BottomSheet::new(true)
384            .on_dismiss(Msg::Close)
385            .sheet(Container::<Msg>::new().height(120.0))
386            .body(Container::<Msg>::new());
387        let ui = build_ui(
388            &sheet,
389            Size::new(500.0, 400.0),
390            &Runtime::default(),
391            &Theme::default(),
392        );
393        let scrim = ui.scene().primitives().iter().any(
394            |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 500.0 && rect.height >= 399.0),
395        );
396        assert!(scrim, "the scrim must cover the window");
397        // The full-width panel (h ≈ 140, not the scrim), flush with the bottom (y+h ≈ 400).
398        let docked = ui.scene().primitives().iter().any(|p| {
399            matches!(p, frus_core::Primitive::Rect { rect, .. }
400                if (rect.width - 500.0).abs() < 1.0 && rect.height < 300.0
401                    && (rect.y + rect.height - 400.0).abs() < 1.0)
402        });
403        assert!(
404            docked,
405            "the panel must be full-width and docked at the bottom"
406        );
407    }
408
409    #[test]
410    fn mid_animation_slides_sheet_up() {
411        let sheet = BottomSheet::new(true)
412            .on_dismiss(Msg::Close)
413            .sheet(Container::<Msg>::new().height(120.0))
414            .body(Container::<Msg>::new());
415        let mut rt = Runtime::default();
416        rt.set_value(crate::WidgetId::ROOT, 0.5);
417        let ui = build_ui(&sheet, Size::new(500.0, 400.0), &rt, &Theme::default());
418        // The top edge of the full-width panel (height ≈ 140, not the 500×400 scrim).
419        let top = ui.scene().primitives().iter().find_map(|p| match p {
420            frus_core::Primitive::Rect { rect, .. }
421                if (rect.width - 500.0).abs() < 1.0 && rect.height < 300.0 =>
422            {
423                Some(rect.y)
424            }
425            _ => None,
426        });
427        let top = top.expect("the sheet's panel must be present");
428        // At linear t=0.5, the sheet has risen by `spring_ease(0.5)·height` from the
429        // bottom: top edge ≈ 400 − spring_ease(0.5)·140 (120 + 20 of padding).
430        let progress = crate::spring_ease(0.5);
431        let sheet_h = 140.0;
432        let expected = 400.0 - progress * sheet_h;
433        assert!(
434            (top - expected).abs() < 2.0,
435            "top edge expected ≈ {expected}, got {top}"
436        );
437    }
438}