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, ViewExt, ZStack, anim::animate_f32_from, overlay::OverlayHandle,
11};
12
13use super::*;
14
15static BOTTOMSHEET_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// Configuration for [`BottomSheet`] / `ModalBottomSheet`.
18#[derive(Clone, Debug)]
19pub struct BottomSheetConfig {
20    pub modifier: Modifier,
21    pub container_color: Color,
22    pub content_color: Color,
23    pub scrim_color: Color,
24    pub tonal_elevation: f32,
25    pub shadow_elevation: f32,
26    pub drag_handle_color: Color,
27    pub shape_radius: f32,
28    pub max_width: f32,
29    pub drag_handle_width: f32,
30    pub drag_handle_height: f32,
31    pub peek_height: f32,
32    pub gestures_enabled: bool,
33}
34
35impl Default for BottomSheetConfig {
36    fn default() -> Self {
37        Self {
38            modifier: Modifier::new(),
39            container_color: BottomSheetDefaults::container_color(),
40            content_color: BottomSheetDefaults::content_color(),
41            scrim_color: BottomSheetDefaults::scrim_color(),
42            tonal_elevation: BottomSheetDefaults::TONAL_ELEVATION,
43            shadow_elevation: 0.0,
44            drag_handle_color: BottomSheetDefaults::drag_handle_color(),
45            shape_radius: BottomSheetDefaults::SHAPE_RADIUS,
46            max_width: BottomSheetDefaults::MAX_WIDTH,
47            drag_handle_width: BottomSheetDefaults::DRAG_HANDLE_WIDTH,
48            drag_handle_height: BottomSheetDefaults::DRAG_HANDLE_HEIGHT,
49            peek_height: BottomSheetDefaults::PEEK_HEIGHT,
50            gestures_enabled: true,
51        }
52    }
53}
54
55pub fn BottomSheet(
56    visible: bool,
57    on_dismiss: impl Fn() + 'static,
58    modifier: Modifier,
59    content: View,
60    config: BottomSheetConfig,
61) -> View {
62    let th = theme();
63    let id = remember(|| BOTTOMSHEET_COUNTER.fetch_add(1, Ordering::Relaxed));
64
65    let opacity = animate_f32_from(
66        format!("bs_opacity_{id}"),
67        if visible { 0.0 } else { 1.0 },
68        if visible { 1.0 } else { 0.0 },
69        th.motion.layout,
70    );
71
72    let keep = visible || opacity > 0.01;
73    if !keep {
74        return Box(Modifier::new());
75    }
76    Column(Modifier::new().fill_max_width()).child((
77        Box(modifier
78            .alpha(opacity)
79            .background(config.container_color)
80            .clip_rounded(config.shape_radius)
81            .then(config.modifier))
82        .child(with_content_color(config.content_color, move || content)),
83        Box(Modifier::new()
84            .width(1.0)
85            .height(0.0)
86            .fill_max_width()
87            .alpha(opacity)
88            .hit_passthrough()
89            .on_pointer_down(move |_| on_dismiss())),
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    // Fresh content each composition (builder captures content once).
144    let current_content = remember_state_with_key("mbs_c", || Box(Modifier::new()));
145    *current_content.borrow_mut() = content;
146
147    // Drag state -> offset_at_drag_start is the anim value when the drag began
148    let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
149    let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
150    let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
151
152    // Animated offset: anim_distance px (off-screen) -> 0px (visible)
153    let anim = remember_state_with_key("mbs_anim", || {
154        AnimatedValue::new(anim_distance, theme().motion.spring)
155    });
156    let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
157    let anim_target = if state.is_visible() {
158        0.0
159    } else {
160        anim_distance
161    };
162
163    {
164        let mut a = anim.borrow_mut();
165        let mut lt = last_target.borrow_mut();
166        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
167            if state.is_visible() {
168                a.set_spec(th.motion.spring);
169            } else {
170                a.set_spec(AnimationSpec::fast());
171            }
172            a.set_target(anim_target);
173            *lt = anim_target;
174        }
175        drop(lt);
176        let still_animating = a.update();
177        if still_animating {
178            request_frame();
179        }
180    }
181
182    let offset = *anim.borrow().get();
183    let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
184
185    if sheet_visible {
186        if overlay_id.get() == 0 {
187            let builder: Rc<dyn Fn() -> View> = Rc::new({
188                let state = state.clone();
189                let anim = anim.clone();
190                let modifier = modifier.clone();
191                let current_content = current_content.clone();
192                let drag_anchor_y = drag_anchor_y.clone();
193                let offset_at_drag_start = offset_at_drag_start.clone();
194                let is_dragging = is_dragging.clone();
195                let anim_distance = anim_distance;
196                move || {
197                    let off = *anim.borrow().get();
198                    let content = current_content.borrow().clone();
199
200                    let mut sheet_mod = modifier
201                        .clone()
202                        .fill_max_width()
203                        .max_width(dp_to_px(config.max_width))
204                        .translate(0.0, off)
205                        .background(config.container_color)
206                        .clip_rounded(config.shape_radius);
207
208                    if config.gestures_enabled {
209                        sheet_mod = sheet_mod
210                            .on_pointer_down({
211                                let anim = anim.clone();
212                                let drag_anchor_y = drag_anchor_y.clone();
213                                let offset_at_drag_start = offset_at_drag_start.clone();
214                                let is_dragging = is_dragging.clone();
215                                move |ev| {
216                                    *drag_anchor_y.borrow_mut() = ev.position.y;
217                                    *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
218                                    *is_dragging.borrow_mut() = true;
219                                }
220                            })
221                            .on_pointer_move({
222                                let anim = anim.clone();
223                                let drag_anchor_y = drag_anchor_y.clone();
224                                let offset_at_drag_start = offset_at_drag_start.clone();
225                                let is_dragging = is_dragging.clone();
226                                move |ev| {
227                                    if !*is_dragging.borrow() {
228                                        return;
229                                    }
230                                    let delta = ev.position.y - *drag_anchor_y.borrow();
231                                    let start_off = *offset_at_drag_start.borrow();
232                                    let total = (start_off + delta).max(0.0);
233                                    anim.borrow_mut().snap_to(total);
234                                    request_frame();
235                                }
236                            })
237                            .on_pointer_up({
238                                let anim = anim.clone();
239                                let is_dragging = is_dragging.clone();
240                                let state = state.clone();
241                                let anim_distance = anim_distance;
242                                move |_| {
243                                    *is_dragging.borrow_mut() = false;
244                                    let current_off = *anim.borrow().get();
245                                    let threshold = anim_distance * 0.3;
246                                    if current_off > threshold {
247                                        anim.borrow_mut().set_target(anim_distance);
248                                        state.dismiss();
249                                    } else {
250                                        anim.borrow_mut().set_target(0.0);
251                                    }
252                                }
253                            });
254                    }
255
256                    let sheet_body = Box(sheet_mod).child(
257                        Column(Modifier::new().fill_max_width()).child((
258                            Row(Modifier::new()
259                                .fill_max_width()
260                                .justify_content(JustifyContent::CENTER))
261                            .child(Box(Modifier::new()
262                                .margin_vertical(22.0)
263                                .width(config.drag_handle_width)
264                                .height(config.drag_handle_height)
265                                .background(config.drag_handle_color)
266                                .clip_rounded(2.0))),
267                            content,
268                        )),
269                    );
270
271                    let sheet = Box(Modifier::new()
272                        .fill_max_size()
273                        .justify_content(JustifyContent::CENTER)
274                        .align_items(AlignItems::FLEX_END))
275                    .child(sheet_body);
276
277                    let scrim_alpha = if state.is_visible() {
278                        config.scrim_color.3
279                    } else {
280                        let t = (off / anim_distance).clamp(0.0, 1.0);
281                        (config.scrim_color.3 as f32 * (1.0 - t)) as u8
282                    };
283                    let scrim = Box(Modifier::new()
284                        .fill_max_size()
285                        .background(config.scrim_color.with_alpha(scrim_alpha))
286                        .input_blocker()
287                        .on_scroll(|_| Vec2::default())
288                        .on_pointer_down({
289                            let s = state.clone();
290                            move |_| s.dismiss()
291                        }));
292
293                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
294                }
295            });
296
297            let id = overlay.show_entry(builder, 900.0, false);
298            overlay_id.set(id);
299        }
300    } else {
301        let prev = overlay_id.get();
302        if prev != 0 {
303            let _ = overlay.dismiss(prev);
304            overlay_id.set(0);
305        }
306    }
307
308    Box(Modifier::new())
309}