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::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)]
77struct BottomSheetProviderStateInner {
78 is_open: bool,
79 timer: Option<Instant>,
80}
81
82#[derive(Clone, Default)]
83pub struct BottomSheetProviderState {
84 inner: Arc<RwLock<BottomSheetProviderStateInner>>,
85}
86
87impl BottomSheetProviderState {
88 pub fn new() -> Self {
90 Self::default()
91 }
92
93 pub fn open(&self) {
98 let mut inner = self.inner.write();
99 if !inner.is_open {
100 inner.is_open = true;
101 let mut timer = Instant::now();
102 if let Some(old_timer) = inner.timer {
103 let elapsed = old_timer.elapsed();
104 if elapsed < ANIM_TIME {
105 timer += ANIM_TIME - elapsed;
106 }
107 }
108 inner.timer = Some(timer);
109 }
110 }
111
112 pub fn close(&self) {
117 let mut inner = self.inner.write();
118 if inner.is_open {
119 inner.is_open = false;
120 let mut timer = Instant::now();
121 if let Some(old_timer) = inner.timer {
122 let elapsed = old_timer.elapsed();
123 if elapsed < ANIM_TIME {
124 timer += ANIM_TIME - elapsed;
125 }
126 }
127 inner.timer = Some(timer);
128 }
129 }
130
131 pub fn is_open(&self) -> bool {
133 self.inner.read().is_open
134 }
135
136 pub fn is_animating(&self) -> bool {
138 self.inner
139 .read()
140 .timer
141 .is_some_and(|t| t.elapsed() < ANIM_TIME)
142 }
143
144 fn snapshot(&self) -> (bool, Option<Instant>) {
145 let inner = self.inner.read();
146 (inner.is_open, inner.timer)
147 }
148}
149
150fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
152 let raw = match timer {
153 None => 1.0,
154 Some(t) => {
155 let elapsed = t.elapsed();
156 if elapsed >= ANIM_TIME {
157 1.0
158 } else {
159 elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
160 }
161 }
162 };
163 animation::easing(raw)
164}
165
166fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
168 if is_open {
169 progress * max_blur_radius
170 } else {
171 max_blur_radius * (1.0 - progress)
172 }
173}
174
175fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
177 if is_open {
178 progress * 0.5
179 } else {
180 0.5 * (1.0 - progress)
181 }
182}
183
184fn compute_bottom_sheet_y(
186 parent_height: Px,
187 child_height: Px,
188 progress: f32,
189 is_open: bool,
190) -> i32 {
191 let parent = parent_height.0 as f32;
192 let child = child_height.0 as f32;
193 let y = if is_open {
194 parent - child * progress
195 } else {
196 parent - child * (1.0 - progress)
197 };
198 y as i32
199}
200
201fn render_glass_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
202 let max_blur_radius = 5.0;
204 let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
205 fluid_glass(
206 FluidGlassArgsBuilder::default()
207 .on_click(args.on_close_request.clone())
208 .tint_color(Color::TRANSPARENT)
209 .width(DimensionValue::Fill {
210 min: None,
211 max: None,
212 })
213 .height(DimensionValue::Fill {
214 min: None,
215 max: None,
216 })
217 .dispersion_height(Dp(0.0))
218 .refraction_height(Dp(0.0))
219 .block_input(true)
220 .blur_radius(Dp(blur_radius as f64))
221 .border(None)
222 .shape(Shape::RoundedRectangle {
223 top_left: Dp(0.0),
224 top_right: Dp(0.0),
225 bottom_right: Dp(0.0),
226 bottom_left: Dp(0.0),
227 g2_k_value: 3.0,
228 })
229 .noise_amount(0.0)
230 .build()
231 .unwrap(),
232 None,
233 || {},
234 );
235}
236
237fn render_material_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
238 let scrim_alpha = scrim_alpha_for(progress, is_open);
240 surface(
241 SurfaceArgsBuilder::default()
242 .style(Color::BLACK.with_alpha(scrim_alpha).into())
243 .on_click(args.on_close_request.clone())
244 .width(DimensionValue::Fill {
245 min: None,
246 max: None,
247 })
248 .height(DimensionValue::Fill {
249 min: None,
250 max: None,
251 })
252 .block_input(true)
253 .build()
254 .unwrap(),
255 None,
256 || {},
257 );
258}
259
260fn render_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
264 match args.style {
265 BottomSheetStyle::Glass => render_glass_scrim(args, progress, is_open),
266 BottomSheetStyle::Material => render_material_scrim(args, progress, is_open),
267 }
268}
269
270fn snapshot_state(state: &BottomSheetProviderState) -> (bool, Option<Instant>) {
272 state.snapshot()
273}
274
275fn make_keyboard_closure(
277 on_close: Arc<dyn Fn() + Send + Sync>,
278) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
279 Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
280 for event in input.keyboard_events.drain(..) {
281 if event.state == winit::event::ElementState::Pressed
282 && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
283 event.physical_key
284 {
285 (on_close)();
286 }
287 }
288 })
289}
290
291fn place_bottom_sheet_if_present(
293 input: &tessera_ui::MeasureInput<'_>,
294 state_for_measure: &BottomSheetProviderState,
295 progress: f32,
296) {
297 if input.children_ids.len() <= 2 {
298 return;
299 }
300
301 let bottom_sheet_id = input.children_ids[2];
302
303 let child_size = match input.measure_child(bottom_sheet_id, input.parent_constraint) {
304 Ok(s) => s,
305 Err(_) => return,
306 };
307
308 let parent_height = input.parent_constraint.height.get_max().unwrap_or(Px(0));
309 let current_is_open = state_for_measure.is_open();
310 let y = compute_bottom_sheet_y(parent_height, child_size.height, progress, current_is_open);
311 input.place_child(bottom_sheet_id, PxPosition::new(Px(0), Px(y)));
312}
313
314fn render_content(
315 style: BottomSheetStyle,
316 bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
317) {
318 match style {
319 BottomSheetStyle::Glass => {
320 fluid_glass(
321 FluidGlassArgsBuilder::default()
322 .shape(Shape::RoundedRectangle {
323 top_left: Dp(50.0),
324 top_right: Dp(50.0),
325 bottom_right: Dp(0.0),
326 bottom_left: Dp(0.0),
327 g2_k_value: 3.0,
328 })
329 .tint_color(Color::new(0.6, 0.8, 1.0, 0.3)) .width(DimensionValue::Fill {
331 min: None,
332 max: None,
333 })
334 .refraction_amount(25.0)
335 .padding(Dp(20.0))
336 .blur_radius(Dp(10.0))
337 .block_input(true)
338 .build()
339 .unwrap(),
340 None,
341 bottom_sheet_content,
342 );
343 }
344 BottomSheetStyle::Material => {
345 surface(
346 SurfaceArgsBuilder::default()
347 .style(Color::new(0.2, 0.2, 0.2, 1.0).into())
348 .shape(Shape::RoundedRectangle {
349 top_left: Dp(25.0),
350 top_right: Dp(25.0),
351 bottom_right: Dp(0.0),
352 bottom_left: Dp(0.0),
353 g2_k_value: 3.0,
354 })
355 .width(DimensionValue::Fill {
356 min: None,
357 max: None,
358 })
359 .padding(Dp(20.0))
360 .block_input(true)
361 .build()
362 .unwrap(),
363 None,
364 bottom_sheet_content,
365 );
366 }
367 }
368}
369
370#[tessera]
400pub fn bottom_sheet_provider(
401 args: BottomSheetProviderArgs,
402 state: BottomSheetProviderState,
403 main_content: impl FnOnce() + Send + Sync + 'static,
404 bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
405) {
406 main_content();
408
409 let (is_open, timer_opt) = snapshot_state(&state);
411
412 if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
414 return;
415 }
416
417 let on_close_for_keyboard = args.on_close_request.clone();
419 let progress = calc_progress_from_timer(timer_opt.as_ref());
420
421 render_scrim(&args, progress, is_open);
423
424 let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
426 input_handler(keyboard_closure);
427
428 render_content(args.style, bottom_sheet_content);
430
431 let state_for_measure = state.clone();
433 let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
434 let main_content_id = input.children_ids[0];
436 let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
437 input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
438
439 if input.children_ids.len() > 1 {
441 let scrim_id = input.children_ids[1];
442 input.measure_child(scrim_id, input.parent_constraint)?;
443 input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
444 }
445
446 place_bottom_sheet_if_present(input, &state_for_measure, progress);
448
449 Ok(main_content_size)
451 });
452 measure(measure_closure);
453}