tessera_ui_basic_components/
bottom_sheet.rs1use std::{
7 sync::Arc,
8 time::{Duration, Instant},
9};
10
11use derive_builder::Builder;
12use parking_lot::RwLock;
13use tessera_ui::{Color, DimensionValue, Dp, Px, PxPosition, tessera, winit};
14
15use crate::{
16 animation,
17 fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
18 shape_def::{RoundedCorner, Shape},
19 surface::{SurfaceArgsBuilder, surface},
20};
21
22const ANIM_TIME: Duration = Duration::from_millis(300);
23
24#[derive(Default, Clone, Copy)]
28pub enum BottomSheetStyle {
29 Glass,
32 #[default]
34 Material,
35}
36
37#[derive(Builder)]
39pub struct BottomSheetProviderArgs {
40 pub on_close_request: Arc<dyn Fn() + Send + Sync>,
45 #[builder(default)]
47 pub style: BottomSheetStyle,
48}
49
50#[derive(Default)]
51struct BottomSheetProviderStateInner {
52 is_open: bool,
53 timer: Option<Instant>,
54}
55
56#[derive(Clone, Default)]
79pub struct BottomSheetProviderState {
80 inner: Arc<RwLock<BottomSheetProviderStateInner>>,
81}
82
83impl BottomSheetProviderState {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn open(&self) {
94 let mut inner = self.inner.write();
95 if !inner.is_open {
96 inner.is_open = true;
97 let mut timer = Instant::now();
98 if let Some(old_timer) = inner.timer {
99 let elapsed = old_timer.elapsed();
100 if elapsed < ANIM_TIME {
101 timer += ANIM_TIME - elapsed;
102 }
103 }
104 inner.timer = Some(timer);
105 }
106 }
107
108 pub fn close(&self) {
113 let mut inner = self.inner.write();
114 if inner.is_open {
115 inner.is_open = false;
116 let mut timer = Instant::now();
117 if let Some(old_timer) = inner.timer {
118 let elapsed = old_timer.elapsed();
119 if elapsed < ANIM_TIME {
120 timer += ANIM_TIME - elapsed;
121 }
122 }
123 inner.timer = Some(timer);
124 }
125 }
126
127 pub fn is_open(&self) -> bool {
129 self.inner.read().is_open
130 }
131
132 pub fn is_animating(&self) -> bool {
134 self.inner
135 .read()
136 .timer
137 .is_some_and(|t| t.elapsed() < ANIM_TIME)
138 }
139
140 fn snapshot(&self) -> (bool, Option<Instant>) {
141 let inner = self.inner.read();
142 (inner.is_open, inner.timer)
143 }
144}
145
146fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
148 let raw = match timer {
149 None => 1.0,
150 Some(t) => {
151 let elapsed = t.elapsed();
152 if elapsed >= ANIM_TIME {
153 1.0
154 } else {
155 elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
156 }
157 }
158 };
159 animation::easing(raw)
160}
161
162fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
164 if is_open {
165 progress * max_blur_radius
166 } else {
167 max_blur_radius * (1.0 - progress)
168 }
169}
170
171fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
173 if is_open {
174 progress * 0.5
175 } else {
176 0.5 * (1.0 - progress)
177 }
178}
179
180fn compute_bottom_sheet_y(
182 parent_height: Px,
183 child_height: Px,
184 progress: f32,
185 is_open: bool,
186) -> i32 {
187 let parent = parent_height.0 as f32;
188 let child = child_height.0 as f32;
189 let y = if is_open {
190 parent - child * progress
191 } else {
192 parent - child * (1.0 - progress)
193 };
194 y as i32
195}
196
197fn render_glass_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
198 let max_blur_radius = 5.0;
200 let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
201 fluid_glass(
202 FluidGlassArgsBuilder::default()
203 .on_click(args.on_close_request.clone())
204 .tint_color(Color::TRANSPARENT)
205 .width(DimensionValue::Fill {
206 min: None,
207 max: None,
208 })
209 .height(DimensionValue::Fill {
210 min: None,
211 max: None,
212 })
213 .dispersion_height(Dp(0.0))
214 .refraction_height(Dp(0.0))
215 .block_input(true)
216 .blur_radius(Dp(blur_radius as f64))
217 .border(None)
218 .shape(Shape::RoundedRectangle {
219 top_left: RoundedCorner::manual(Dp(0.0), 3.0),
220 top_right: RoundedCorner::manual(Dp(0.0), 3.0),
221 bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
222 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
223 })
224 .noise_amount(0.0)
225 .build()
226 .expect("FluidGlassArgsBuilder failed with required fields set"),
227 None,
228 || {},
229 );
230}
231
232fn render_material_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
233 let scrim_alpha = scrim_alpha_for(progress, is_open);
235 surface(
236 SurfaceArgsBuilder::default()
237 .style(Color::BLACK.with_alpha(scrim_alpha).into())
238 .on_click(args.on_close_request.clone())
239 .width(DimensionValue::Fill {
240 min: None,
241 max: None,
242 })
243 .height(DimensionValue::Fill {
244 min: None,
245 max: None,
246 })
247 .block_input(true)
248 .build()
249 .expect("SurfaceArgsBuilder failed with required fields set"),
250 None,
251 || {},
252 );
253}
254
255fn render_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
259 match args.style {
260 BottomSheetStyle::Glass => render_glass_scrim(args, progress, is_open),
261 BottomSheetStyle::Material => render_material_scrim(args, progress, is_open),
262 }
263}
264
265fn snapshot_state(state: &BottomSheetProviderState) -> (bool, Option<Instant>) {
267 state.snapshot()
268}
269
270fn make_keyboard_closure(
272 on_close: Arc<dyn Fn() + Send + Sync>,
273) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
274 Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
275 for event in input.keyboard_events.drain(..) {
276 if event.state == winit::event::ElementState::Pressed
277 && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
278 event.physical_key
279 {
280 (on_close)();
281 }
282 }
283 })
284}
285
286fn place_bottom_sheet_if_present(
288 input: &tessera_ui::MeasureInput<'_>,
289 state_for_measure: &BottomSheetProviderState,
290 progress: f32,
291) {
292 if input.children_ids.len() <= 2 {
293 return;
294 }
295
296 let bottom_sheet_id = input.children_ids[2];
297
298 let child_size = match input.measure_child(bottom_sheet_id, input.parent_constraint) {
299 Ok(s) => s,
300 Err(_) => return,
301 };
302
303 let parent_height = input.parent_constraint.height.get_max().unwrap_or(Px(0));
304 let current_is_open = state_for_measure.is_open();
305 let y = compute_bottom_sheet_y(parent_height, child_size.height, progress, current_is_open);
306 input.place_child(bottom_sheet_id, PxPosition::new(Px(0), Px(y)));
307}
308
309fn render_content(
310 style: BottomSheetStyle,
311 bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
312) {
313 match style {
314 BottomSheetStyle::Glass => {
315 fluid_glass(
316 FluidGlassArgsBuilder::default()
317 .shape(Shape::RoundedRectangle {
318 top_left: RoundedCorner::manual(Dp(50.0), 3.0),
319 top_right: RoundedCorner::manual(Dp(50.0), 3.0),
320 bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
321 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
322 })
323 .tint_color(Color::new(0.6, 0.8, 1.0, 0.3)) .width(DimensionValue::Fill {
325 min: None,
326 max: None,
327 })
328 .refraction_amount(25.0)
329 .padding(Dp(20.0))
330 .blur_radius(Dp(10.0))
331 .block_input(true)
332 .build()
333 .expect("FluidGlassArgsBuilder failed with required fields set"),
334 None,
335 bottom_sheet_content,
336 );
337 }
338 BottomSheetStyle::Material => {
339 surface(
340 SurfaceArgsBuilder::default()
341 .style(Color::new(0.2, 0.2, 0.2, 1.0).into())
342 .shape(Shape::RoundedRectangle {
343 top_left: RoundedCorner::manual(Dp(25.0), 3.0),
344 top_right: RoundedCorner::manual(Dp(25.0), 3.0),
345 bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
346 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
347 })
348 .width(DimensionValue::Fill {
349 min: None,
350 max: None,
351 })
352 .padding(Dp(20.0))
353 .block_input(true)
354 .build()
355 .expect("SurfaceArgsBuilder failed with required fields set"),
356 None,
357 bottom_sheet_content,
358 );
359 }
360 }
361}
362
363#[tessera]
393pub fn bottom_sheet_provider(
394 args: BottomSheetProviderArgs,
395 state: BottomSheetProviderState,
396 main_content: impl FnOnce() + Send + Sync + 'static,
397 bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
398) {
399 main_content();
401
402 let (is_open, timer_opt) = snapshot_state(&state);
404
405 if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
407 return;
408 }
409
410 let on_close_for_keyboard = args.on_close_request.clone();
412 let progress = calc_progress_from_timer(timer_opt.as_ref());
413
414 render_scrim(&args, progress, is_open);
416
417 let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
419 input_handler(keyboard_closure);
420
421 render_content(args.style, bottom_sheet_content);
423
424 let state_for_measure = state.clone();
426 let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
427 let main_content_id = input.children_ids[0];
429 let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
430 input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
431
432 if input.children_ids.len() > 1 {
434 let scrim_id = input.children_ids[1];
435 input.measure_child(scrim_id, input.parent_constraint)?;
436 input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
437 }
438
439 place_bottom_sheet_if_present(input, &state_for_measure, progress);
441
442 Ok(main_content_size)
444 });
445 measure(measure_closure);
446}