1use 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
25const GRABBER_WIDTH: f32 = 36.0;
27const GRABBER_HEIGHT: f32 = 4.0;
28
29struct SheetPanel<Msg> {
32 children: Vec<Box<dyn Widget<Msg>>>,
33 background: Option<Color>,
35 shape: Option<ShapeBorder>,
37}
38
39impl<Msg> SheetPanel<Msg> {
40 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 fn style(&self) -> Style {
64 Style {
65 height: Dimension::Auto,
67 flex_direction: FlexDirection::Column,
68 padding: Insets::new(20.0, 0.0, 0.0, 0.0),
70 ..Default::default()
71 }
72 }
73
74 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 let shape = self.shape_of(theme);
88 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 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 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
127pub struct BottomSheet<Msg = crate::callback::Callback> {
129 open: bool,
130 on_dismiss: Option<Msg>,
131 sheet_content: Option<Box<dyn Widget<Msg>>>,
133 modal_panel: Option<Box<dyn Widget<Msg>>>,
135 background: Option<Color>,
137 shape: Option<ShapeBorder>,
139 children: Vec<Box<dyn Widget<Msg>>>,
141}
142
143impl<Msg: Clone + 'static> BottomSheet<Msg> {
144 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 #[must_use]
160 pub fn background(mut self, color: Color) -> Self {
161 self.background = Some(color);
162 self
163 }
164
165 #[must_use]
172 pub fn shape(mut self, shape: ShapeBorder) -> Self {
173 self.shape = Some(shape);
174 self
175 }
176
177 pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
179 self.on_dismiss = Some(message.into());
180 self
181 }
182
183 pub fn sheet(mut self, content: impl Widget<Msg> + 'static) -> Self {
185 self.sheet_content = Some(Box::new(content));
186 self
187 }
188
189 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 fn style(&self) -> Style {
210 Style {
211 height: Dimension::Percent(1.0),
212 flex_direction: FlexDirection::Column,
213 ..Default::default()
214 }
215 }
216
217 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 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 Some(if self.open { 1.0 } else { 0.0 })
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 #[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 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 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 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 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 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}