repose_material/material3/
bottom_sheet.rs1#![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#[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, ) -> 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
93pub 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
127pub 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 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 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}