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, // HACK: use ot
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        Column(Modifier::new()).child((
75            Box(modifier.alpha(opacity)).child(content),
76            Box(Modifier::new()
77                .width(1.0)
78                .height(0.0)
79                .fill_max_width()
80                .alpha(opacity)
81                .hit_passthrough()
82                .on_pointer_down(move |_| on_dismiss())),
83        ))
84    } else {
85        Box(Modifier::new())
86    }
87}
88
89/// State for `ModalBottomSheet` - manages visibility and drag offset.
90pub struct SheetState {
91    visible: Signal<bool>,
92    drag_offset: Signal<f32>,
93    peek_height: Signal<f32>,
94}
95
96impl SheetState {
97    pub fn new(peek_height: f32) -> Self {
98        Self {
99            visible: signal(false),
100            drag_offset: signal(0.0),
101            peek_height: signal(peek_height),
102        }
103    }
104
105    pub fn is_visible(&self) -> bool {
106        self.visible.get()
107    }
108
109    pub fn show(&self) {
110        self.visible.set(true);
111    }
112
113    pub fn dismiss(&self) {
114        self.visible.set(false);
115        self.drag_offset.set(0.0);
116    }
117
118    pub fn set_peek_height(&self, h: f32) {
119        self.peek_height.set(h);
120    }
121}
122
123/// M3 Modal Bottom Sheet - slides up from the bottom with a drag handle.
124///
125/// Renders as an overlay so it is not clipped by parent containers.
126/// Shows on `state.show()`, dismisses on `state.dismiss()` or scrim tap.
127pub fn ModalBottomSheet(
128    state: Rc<SheetState>,
129    overlay: OverlayHandle,
130    modifier: Modifier,
131    content: View,
132    config: BottomSheetConfig,
133) -> View {
134    let th = theme();
135    let peek_h = state.peek_height.get().max(config.peek_height);
136    let anim_distance = peek_h.max(48.0).max(400.0);
137    let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
138
139    // Drag state -> offset_at_drag_start is the anim value when the drag began
140    let drag_anchor_y: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_y", || 0.0);
141    let offset_at_drag_start: Rc<RefCell<f32>> = remember_state_with_key("mbs_drag_base", || 0.0);
142    let is_dragging: Rc<RefCell<bool>> = remember_state_with_key("mbs_drag", || false);
143
144    // Animated offset: anim_distance px (off-screen) -> 0px (visible)
145    let anim = remember_state_with_key("mbs_anim", || {
146        AnimatedValue::new(anim_distance, theme().motion.spring)
147    });
148    let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
149    let anim_target = if state.is_visible() {
150        0.0
151    } else {
152        anim_distance
153    };
154
155    {
156        let mut a = anim.borrow_mut();
157        let mut lt = last_target.borrow_mut();
158        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
159            if state.is_visible() {
160                a.set_spec(th.motion.spring);
161            } else {
162                a.set_spec(AnimationSpec::fast());
163            }
164            a.set_target(anim_target);
165            *lt = anim_target;
166        }
167        drop(lt);
168        let still_animating = a.update();
169        if still_animating {
170            request_frame();
171        }
172    }
173
174    let offset = *anim.borrow().get();
175    let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
176
177    if sheet_visible {
178        if overlay_id.get() == 0 {
179            let builder: Rc<dyn Fn() -> View> = Rc::new({
180                let state = state.clone();
181                let anim = anim.clone();
182                let modifier = modifier.clone();
183                let content = content.clone();
184                let drag_anchor_y = drag_anchor_y.clone();
185                let offset_at_drag_start = offset_at_drag_start.clone();
186                let is_dragging = is_dragging.clone();
187                let anim_distance = anim_distance;
188                move || {
189                    let off = *anim.borrow().get();
190
191                    let sheet_body = Box(modifier
192                        .clone()
193                        .fill_max_width()
194                        .max_width(dp_to_px(config.max_width))
195                        .translate(0.0, off)
196                        .background(config.container_color)
197                        .clip_rounded(config.shape_radius)
198                        .on_pointer_down({
199                            let anim = anim.clone();
200                            let drag_anchor_y = drag_anchor_y.clone();
201                            let offset_at_drag_start = offset_at_drag_start.clone();
202                            let is_dragging = is_dragging.clone();
203                            move |ev| {
204                                *drag_anchor_y.borrow_mut() = ev.position.y;
205                                *offset_at_drag_start.borrow_mut() = *anim.borrow().get();
206                                *is_dragging.borrow_mut() = true;
207                            }
208                        })
209                        .on_pointer_move({
210                            let anim = anim.clone();
211                            let drag_anchor_y = drag_anchor_y.clone();
212                            let offset_at_drag_start = offset_at_drag_start.clone();
213                            let is_dragging = is_dragging.clone();
214                            move |ev| {
215                                if !*is_dragging.borrow() {
216                                    return;
217                                }
218                                let delta = ev.position.y - *drag_anchor_y.borrow();
219                                let start_off = *offset_at_drag_start.borrow();
220                                let total = (start_off + delta).max(0.0);
221                                anim.borrow_mut().snap_to(total);
222                                request_frame();
223                            }
224                        })
225                        .on_pointer_up({
226                            let anim = anim.clone();
227                            let is_dragging = is_dragging.clone();
228                            let state = state.clone();
229                            let anim_distance = anim_distance;
230                            move |_| {
231                                *is_dragging.borrow_mut() = false;
232                                let current_off = *anim.borrow().get();
233                                let threshold = anim_distance * 0.3;
234                                if current_off > threshold {
235                                    anim.borrow_mut().set_target(anim_distance);
236                                    state.dismiss();
237                                } else {
238                                    anim.borrow_mut().set_target(0.0);
239                                }
240                            }
241                        }))
242                    .child(
243                        Column(Modifier::new().fill_max_width()).child((
244                            Row(Modifier::new()
245                                .fill_max_width()
246                                .justify_content(JustifyContent::CENTER))
247                            .child(Box(Modifier::new()
248                                .margin_vertical(22.0)
249                                .width(config.drag_handle_width)
250                                .height(config.drag_handle_height)
251                                .background(config.drag_handle_color)
252                                .clip_rounded(2.0))),
253                            content.clone(),
254                        )),
255                    );
256
257                    let sheet = Box(Modifier::new()
258                        .fill_max_size()
259                        .justify_content(JustifyContent::CENTER)
260                        .align_items(AlignItems::FLEX_END))
261                    .child(sheet_body);
262
263                    let scrim_alpha = if state.is_visible() {
264                        config.scrim_color.3
265                    } else {
266                        let t = (off / anim_distance).clamp(0.0, 1.0);
267                        (config.scrim_color.3 as f32 * (1.0 - t)) as u8
268                    };
269                    let scrim = Box(Modifier::new()
270                        .fill_max_size()
271                        .background(config.scrim_color.with_alpha(scrim_alpha))
272                        .on_pointer_down({
273                            let s = state.clone();
274                            move |_| s.dismiss()
275                        }));
276
277                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
278                }
279            });
280
281            let id = overlay.show_entry(builder, 900.0, false);
282            overlay_id.set(id);
283        }
284    } else {
285        let prev = overlay_id.get();
286        if prev != 0 {
287            let _ = overlay.dismiss(prev);
288            overlay_id.set(0);
289        }
290    }
291
292    Box(Modifier::new())
293}