Skip to main content

repose_material/material3/
bottom_sheet.rs

1#![allow(non_snake_case)]
2
3use std::cell::RefCell;
4use std::rc::Rc;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7use repose_core::animation::AnimationSpec;
8use repose_core::*;
9use repose_ui::{
10    Box, Column, Row,
11    ViewExt, ZStack,
12    anim::animate_f32_from,
13    overlay::OverlayHandle,
14};
15
16use super::*;
17
18static BOTTOMSHEET_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20/// Configuration for [`BottomSheet`] / `ModalBottomSheet`.
21#[derive(Clone, Debug)]
22pub struct BottomSheetConfig {
23    pub modifier: Modifier,
24    pub container_color: Color,
25    pub content_color: Color,
26    pub scrim_color: Color,
27    pub tonal_elevation: f32,
28    pub shadow_elevation: f32,
29    pub drag_handle_color: Color,
30    pub shape_radius: f32,
31    pub max_width: f32,
32    pub drag_handle_width: f32,
33    pub drag_handle_height: f32,
34    pub peek_height: f32,
35    pub gestures_enabled: bool,
36}
37
38impl Default for BottomSheetConfig {
39    fn default() -> Self {
40        Self {
41            modifier: Modifier::new(),
42            container_color: BottomSheetDefaults::container_color(),
43            content_color: BottomSheetDefaults::content_color(),
44            scrim_color: BottomSheetDefaults::scrim_color(),
45            tonal_elevation: BottomSheetDefaults::TONAL_ELEVATION,
46            shadow_elevation: 0.0,
47            drag_handle_color: BottomSheetDefaults::drag_handle_color(),
48            shape_radius: BottomSheetDefaults::SHAPE_RADIUS,
49            max_width: BottomSheetDefaults::MAX_WIDTH,
50            drag_handle_width: BottomSheetDefaults::DRAG_HANDLE_WIDTH,
51            drag_handle_height: BottomSheetDefaults::DRAG_HANDLE_HEIGHT,
52            peek_height: BottomSheetDefaults::PEEK_HEIGHT,
53            gestures_enabled: true,
54        }
55    }
56}
57
58pub fn BottomSheet(
59    visible: bool,
60    on_dismiss: impl Fn() + 'static,
61    modifier: Modifier,
62    content: View,
63    _config: BottomSheetConfig, // HACK: use ot
64) -> View {
65    let th = theme();
66    let id = remember(|| BOTTOMSHEET_COUNTER.fetch_add(1, Ordering::Relaxed));
67
68    let opacity = animate_f32_from(
69        format!("bs_opacity_{id}"),
70        if visible { 0.0 } else { 1.0 },
71        if visible { 1.0 } else { 0.0 },
72        th.motion.layout,
73    );
74
75    let keep = visible || opacity > 0.01;
76    if keep {
77        Column(Modifier::new()).child((
78            Box(modifier.alpha(opacity)).child(content),
79            Box(Modifier::new()
80                .width(1.0)
81                .height(0.0)
82                .fill_max_width()
83                .alpha(opacity)
84                .hit_passthrough()
85                .on_pointer_down(move |_| on_dismiss())),
86        ))
87    } else {
88        Box(Modifier::new())
89    }
90}
91
92
93/// State for `ModalBottomSheet` - manages visibility and drag offset.
94pub struct SheetState {
95    visible: Signal<bool>,
96    drag_offset: Signal<f32>,
97    peek_height: Signal<f32>,
98}
99
100impl SheetState {
101    pub fn new(peek_height: f32) -> Self {
102        Self {
103            visible: signal(false),
104            drag_offset: signal(0.0),
105            peek_height: signal(peek_height),
106        }
107    }
108
109    pub fn is_visible(&self) -> bool {
110        self.visible.get()
111    }
112
113    pub fn show(&self) {
114        self.visible.set(true);
115    }
116
117    pub fn dismiss(&self) {
118        self.visible.set(false);
119        self.drag_offset.set(0.0);
120    }
121
122    pub fn set_peek_height(&self, h: f32) {
123        self.peek_height.set(h);
124    }
125}
126
127/// M3 Modal Bottom Sheet - slides up from the bottom with a drag handle.
128///
129/// Renders as an overlay so it is not clipped by parent containers.
130/// Shows on `state.show()`, dismisses on `state.dismiss()` or scrim tap.
131pub fn ModalBottomSheet(
132    state: Rc<SheetState>,
133    overlay: OverlayHandle,
134    modifier: Modifier,
135    content: View,
136    config: BottomSheetConfig,
137) -> View {
138    let th = theme();
139    let peek_h = state.peek_height.get().max(config.peek_height);
140    let anim_distance = peek_h.max(48.0).max(400.0);
141    let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
142
143    // Drag state -> offset_at_drag_start is the anim value when the drag began
144    let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
145    let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
146    let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
147
148    // Animated offset: anim_distance px (off-screen) -> 0px (visible)
149    let anim = remember_state_with_key("mbs_anim", || {
150        AnimatedValue::new(anim_distance, theme().motion.spring)
151    });
152    let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
153    let anim_target = if state.is_visible() {
154        0.0
155    } else {
156        anim_distance
157    };
158
159    {
160        let mut a = anim.borrow_mut();
161        let mut lt = last_target.borrow_mut();
162        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
163            if state.is_visible() {
164                a.set_spec(th.motion.spring);
165            } else {
166                a.set_spec(AnimationSpec::fast());
167            }
168            a.set_target(anim_target);
169            *lt = anim_target;
170        }
171        drop(lt);
172        let still_animating = a.update();
173        if still_animating {
174            request_frame();
175        }
176    }
177
178    let offset = *anim.borrow().get();
179    let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
180
181    if sheet_visible {
182        if overlay_id.get() == 0 {
183            let builder: Rc<dyn Fn() -> View> = Rc::new({
184                let state = state.clone();
185                let anim = anim.clone();
186                let modifier = modifier.clone();
187                let content = content.clone();
188                let drag_anchor_y = drag_anchor_y.clone();
189                let offset_at_drag_start = offset_at_drag_start.clone();
190                let is_dragging = is_dragging.clone();
191                let anim_distance = anim_distance;
192                move || {
193                    let off = *anim.borrow().get();
194
195                    let sheet_body = Box(modifier
196                        .clone()
197                        .fill_max_width()
198                        .max_width(dp_to_px(config.max_width))
199                        .translate(0.0, off)
200                        .background(config.container_color)
201                        .clip_rounded(config.shape_radius)
202                        .on_pointer_down({
203                            let anim = anim.clone();
204                            let drag_anchor_y = drag_anchor_y.clone();
205                            let offset_at_drag_start = offset_at_drag_start.clone();
206                            let is_dragging = is_dragging.clone();
207                            move |ev| {
208                                *drag_anchor_y.borrow_mut() = ev.position.y;
209                                *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
210                                *is_dragging.borrow_mut() = true;
211                            }
212                        })
213                        .on_pointer_move({
214                            let anim = anim.clone();
215                            let drag_anchor_y = drag_anchor_y.clone();
216                            let offset_at_drag_start = offset_at_drag_start.clone();
217                            let is_dragging = is_dragging.clone();
218                            move |ev| {
219                                if !*is_dragging.borrow() {
220                                    return;
221                                }
222                                let delta = ev.position.y - *drag_anchor_y.borrow();
223                                let start_off = *offset_at_drag_start.borrow();
224                                let total = (start_off + delta).max(0.0);
225                                anim.borrow_mut().snap_to(total);
226                                request_frame();
227                            }
228                        })
229                        .on_pointer_up({
230                            let anim = anim.clone();
231                            let is_dragging = is_dragging.clone();
232                            let state = state.clone();
233                            let anim_distance = anim_distance;
234                            move |_| {
235                                *is_dragging.borrow_mut() = false;
236                                let current_off = *anim.borrow().get();
237                                let threshold = anim_distance * 0.3;
238                                if current_off > threshold {
239                                    anim.borrow_mut().set_target(anim_distance);
240                                    state.dismiss();
241                                } else {
242                                    anim.borrow_mut().set_target(0.0);
243                                }
244                            }
245                        }))
246                    .child(
247                        Column(Modifier::new().fill_max_width()).child((
248                            Row(Modifier::new()
249                                .fill_max_width()
250                                .justify_content(JustifyContent::CENTER))
251                            .child(Box(Modifier::new()
252                                .margin_vertical(22.0)
253                                .width(config.drag_handle_width)
254                                .height(config.drag_handle_height)
255                                .background(config.drag_handle_color)
256                                .clip_rounded(2.0))),
257                            content.clone(),
258                        )),
259                    );
260
261                    let sheet = Box(Modifier::new()
262                        .fill_max_size()
263                        .justify_content(JustifyContent::CENTER)
264                        .align_items(AlignItems::FLEX_END))
265                    .child(sheet_body);
266
267                    let scrim_alpha = if state.is_visible() {
268                        config.scrim_color.3
269                    } else {
270                        let t = (off / anim_distance).clamp(0.0, 1.0);
271                        (config.scrim_color.3 as f32 * (1.0 - t)) as u8
272                    };
273                    let scrim = Box(Modifier::new()
274                        .fill_max_size()
275                        .background(config.scrim_color.with_alpha(scrim_alpha))
276                        .on_pointer_down({
277                            let s = state.clone();
278                            move |_| s.dismiss()
279                        }));
280
281                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
282                }
283            });
284
285            let id = overlay.show_entry(builder, 900.0, false);
286            overlay_id.set(id);
287        }
288    } else {
289        let prev = overlay_id.get();
290        if prev != 0 {
291            let _ = overlay.dismiss(prev);
292            overlay_id.set(0);
293        }
294    }
295
296    Box(Modifier::new())
297}