tessera_ui_basic_components/bottom_sheet.rs
1//! A component that displays content sliding up from the bottom of the screen.
2//!
3//! The `bottom_sheet_provider` is the core of this module. It manages the presentation
4//! and dismissal of a "bottom sheet" — a common UI pattern for showing contextual
5//! information or actions.
6//!
7//! # Key Components
8//!
9//! * **[`bottom_sheet_provider`]**: The main component function that you call to create the UI.
10//! It orchestrates the main content, the scrim (background overlay), and the sheet content itself.
11//! * **[`BottomSheetProviderState`]**: A state object that you must create and manage to control
12//! the bottom sheet. Use its [`open()`](BottomSheetProviderState::open) and
13//! [`close()`](BottomSheetProviderState::close) methods to show and hide the sheet.
14//! * **[`BottomSheetProviderArgs`]**: Configuration for the provider, including the visual
15//! [`style`](BottomSheetStyle) and the mandatory `on_close_request` callback.
16//! * **[`BottomSheetStyle`]**: Defines the appearance of the background scrim, either `Material`
17//! (a simple dark overlay) or `Glass` (a blurred, translucent effect).
18//!
19//! # Behavior
20//!
21//! - The sheet animates smoothly into and out of view.
22//! - It displays a background scrim that blocks interaction with the main content.
23//! - Clicking the scrim or pressing the `Escape` key triggers the `on_close_request` callback.
24//!
25//! # Example
26//!
27//! ```
28//! use std::sync::Arc;
29//! use parking_lot::RwLock;
30//! use tessera_ui::{tessera, Renderer};
31//! use tessera_ui_basic_components::{
32//! bottom_sheet::{
33//! bottom_sheet_provider, BottomSheetProviderArgsBuilder, BottomSheetProviderState
34//! },
35//! button::{button, ButtonArgsBuilder},
36//! ripple_state::RippleState,
37//! text::{text, TextArgsBuilder},
38//! };
39//!
40//! // 1. Define an application state to hold the bottom sheet's state.
41//! #[derive(Default)]
42//! struct AppState {
43//! sheet_state: Arc<RwLock<BottomSheetProviderState>>,
44//! ripple_state: Arc<RippleState>,
45//! }
46//!
47//! #[tessera]
48//! fn app(state: Arc<RwLock<AppState>>) {
49//! let sheet_state = state.read().sheet_state.clone();
50//!
51//! // 2. Use the bottom_sheet_provider.
52//! bottom_sheet_provider(
53//! BottomSheetProviderArgsBuilder::default()
54//! // 3. Provide a callback to handle close requests.
55//! .on_close_request(Arc::new({
56//! let sheet_state = sheet_state.clone();
57//! move || sheet_state.write().close()
58//! }))
59//! .build()
60//! .unwrap(),
61//! sheet_state.clone(),
62//! // 4. Define the main content that is always visible.
63//! move || {
64//! button(
65//! ButtonArgsBuilder::default()
66//! .on_click(Arc::new({
67//! let sheet_state = sheet_state.clone();
68//! move || sheet_state.write().open()
69//! }))
70//! .build()
71//! .unwrap(),
72//! state.read().ripple_state.clone(),
73//! || text(TextArgsBuilder::default().text("Show Sheet".to_string()).build().unwrap())
74//! );
75//! },
76//! // 5. Define the content of the bottom sheet itself.
77//! || {
78//! text(TextArgsBuilder::default().text("This is the bottom sheet!".to_string()).build().unwrap());
79//! }
80//! );
81//! }
82//! ```
83use std::{
84 sync::Arc,
85 time::{Duration, Instant},
86};
87
88use derive_builder::Builder;
89use parking_lot::RwLock;
90use tessera_ui::{Color, DimensionValue, Dp, Px, PxPosition, tessera, winit};
91
92use crate::{
93 animation,
94 fluid_glass::{FluidGlassArgsBuilder, fluid_glass},
95 shape_def::Shape,
96 surface::{SurfaceArgsBuilder, surface},
97};
98
99const ANIM_TIME: Duration = Duration::from_millis(300);
100
101/// Defines the visual style of the bottom sheet's scrim.
102///
103/// The scrim is the overlay that appears behind the bottom sheet, covering the main content.
104#[derive(Default, Clone, Copy)]
105pub enum BottomSheetStyle {
106 /// A translucent glass effect that blurs the content behind it.
107 /// This style is more resource-intensive and may not be suitable for all targets.
108 Glass,
109 /// A simple, semi-transparent dark overlay. This is the default style.
110 #[default]
111 Material,
112}
113
114/// Configuration arguments for the [`bottom_sheet_provider`].
115#[derive(Builder)]
116pub struct BottomSheetProviderArgs {
117 /// A callback that is invoked when the user requests to close the sheet.
118 ///
119 /// This can be triggered by clicking the scrim or pressing the `Escape` key.
120 /// The callback is responsible for calling [`BottomSheetProviderState::close()`].
121 pub on_close_request: Arc<dyn Fn() + Send + Sync>,
122 /// The visual style of the scrim. See [`BottomSheetStyle`].
123 #[builder(default)]
124 pub style: BottomSheetStyle,
125}
126
127/// Manages the open/closed state of a [`bottom_sheet_provider`].
128///
129/// This state object must be created by the application and passed to the
130/// [`bottom_sheet_provider`]. It is used to control the visibility of the sheet
131/// programmatically.
132///
133/// For safe shared access across different parts of your UI (e.g., a button that opens
134/// the sheet and the provider itself), this state should be wrapped in an `Arc<RwLock<>>`.
135///
136/// # Example
137///
138/// ```
139/// use std::sync::Arc;
140/// use parking_lot::RwLock;
141/// use tessera_ui_basic_components::bottom_sheet::BottomSheetProviderState;
142///
143/// // Create the state, wrapped for shared access.
144/// let sheet_state = Arc::new(RwLock::new(BottomSheetProviderState::default()));
145///
146/// // Later, in an event handler (e.g., a button click):
147/// sheet_state.write().open();
148///
149/// // Or to close it:
150/// sheet_state.write().close();
151/// ```
152#[derive(Default)]
153pub struct BottomSheetProviderState {
154 is_open: bool,
155 timer: Option<Instant>,
156}
157
158impl BottomSheetProviderState {
159 /// Initiates the animation to open the bottom sheet.
160 ///
161 /// If the sheet is already open, this has no effect. If the sheet is currently
162 /// closing, it will reverse direction and start opening from its current position.
163 pub fn open(&mut self) {
164 if !self.is_open {
165 self.is_open = true;
166 let mut timer = Instant::now();
167 if let Some(old_timer) = self.timer {
168 let elapsed = old_timer.elapsed();
169 if elapsed < ANIM_TIME {
170 timer += ANIM_TIME - elapsed;
171 }
172 }
173 self.timer = Some(timer);
174 }
175 }
176
177 /// Initiates the animation to close the bottom sheet.
178 ///
179 /// If the sheet is already closed, this has no effect. If the sheet is currently
180 /// opening, it will reverse direction and start closing from its current position.
181 pub fn close(&mut self) {
182 if self.is_open {
183 self.is_open = false;
184 let mut timer = Instant::now();
185 if let Some(old_timer) = self.timer {
186 let elapsed = old_timer.elapsed();
187 if elapsed < ANIM_TIME {
188 timer += ANIM_TIME - elapsed;
189 }
190 }
191 self.timer = Some(timer);
192 }
193 }
194}
195
196/// Compute eased progress from an optional timer reference.
197fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
198 let raw = match timer {
199 None => 1.0,
200 Some(t) => {
201 let elapsed = t.elapsed();
202 if elapsed >= ANIM_TIME {
203 1.0
204 } else {
205 elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
206 }
207 }
208 };
209 animation::easing(raw)
210}
211
212/// Compute blur radius for glass style.
213fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
214 if is_open {
215 progress * max_blur_radius
216 } else {
217 max_blur_radius * (1.0 - progress)
218 }
219}
220
221/// Compute scrim alpha for material style.
222fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
223 if is_open {
224 progress * 0.5
225 } else {
226 0.5 * (1.0 - progress)
227 }
228}
229
230/// Compute Y position for bottom sheet placement.
231fn compute_bottom_sheet_y(
232 parent_height: Px,
233 child_height: Px,
234 progress: f32,
235 is_open: bool,
236) -> i32 {
237 let parent = parent_height.0 as f32;
238 let child = child_height.0 as f32;
239 let y = if is_open {
240 parent - child * progress
241 } else {
242 parent - child * (1.0 - progress)
243 };
244 y as i32
245}
246
247fn render_glass_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
248 // Glass scrim: compute blur radius and render using fluid_glass.
249 let max_blur_radius = 5.0;
250 let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
251 fluid_glass(
252 FluidGlassArgsBuilder::default()
253 .on_click(args.on_close_request.clone())
254 .tint_color(Color::TRANSPARENT)
255 .width(DimensionValue::Fill {
256 min: None,
257 max: None,
258 })
259 .height(DimensionValue::Fill {
260 min: None,
261 max: None,
262 })
263 .dispersion_height(0.0)
264 .refraction_height(0.0)
265 .block_input(true)
266 .blur_radius(blur_radius)
267 .border(None)
268 .shape(Shape::RoundedRectangle {
269 top_left: Dp(0.0),
270 top_right: Dp(0.0),
271 bottom_right: Dp(0.0),
272 bottom_left: Dp(0.0),
273 g2_k_value: 3.0,
274 })
275 .noise_amount(0.0)
276 .build()
277 .unwrap(),
278 None,
279 || {},
280 );
281}
282
283fn render_material_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
284 // Material scrim: compute alpha and render a simple dark surface.
285 let scrim_alpha = scrim_alpha_for(progress, is_open);
286 surface(
287 SurfaceArgsBuilder::default()
288 .style(Color::BLACK.with_alpha(scrim_alpha).into())
289 .on_click(args.on_close_request.clone())
290 .width(DimensionValue::Fill {
291 min: None,
292 max: None,
293 })
294 .height(DimensionValue::Fill {
295 min: None,
296 max: None,
297 })
298 .block_input(true)
299 .build()
300 .unwrap(),
301 None,
302 || {},
303 );
304}
305
306/// Render scrim according to configured style.
307/// Delegates actual rendering to small, focused helpers to keep the
308/// main API surface concise and improve readability.
309fn render_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
310 match args.style {
311 BottomSheetStyle::Glass => render_glass_scrim(args, progress, is_open),
312 BottomSheetStyle::Material => render_material_scrim(args, progress, is_open),
313 }
314}
315
316/// Snapshot provider state to reduce lock duration and centralize access.
317fn snapshot_state(state: &Arc<RwLock<BottomSheetProviderState>>) -> (bool, Option<Instant>) {
318 let s = state.read();
319 (s.is_open, s.timer)
320}
321
322/// Create the keyboard handler closure used to close the sheet on Escape.
323fn make_keyboard_closure(
324 on_close: Arc<dyn Fn() + Send + Sync>,
325) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
326 Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
327 for event in input.keyboard_events.drain(..) {
328 if event.state == winit::event::ElementState::Pressed
329 && let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
330 event.physical_key
331 {
332 (on_close)();
333 }
334 }
335 })
336}
337
338/// Place bottom sheet if present. Extracted to reduce complexity of the parent function.
339fn place_bottom_sheet_if_present(
340 input: &tessera_ui::MeasureInput<'_>,
341 state_for_measure: &Arc<RwLock<BottomSheetProviderState>>,
342 progress: f32,
343) {
344 if input.children_ids.len() <= 2 {
345 return;
346 }
347
348 let bottom_sheet_id = input.children_ids[2];
349
350 let child_size = match input.measure_child(bottom_sheet_id, input.parent_constraint) {
351 Ok(s) => s,
352 Err(_) => return,
353 };
354
355 let parent_height = input.parent_constraint.height.get_max().unwrap_or(Px(0));
356 let current_is_open = state_for_measure.read().is_open;
357 let y = compute_bottom_sheet_y(parent_height, child_size.height, progress, current_is_open);
358 input.place_child(bottom_sheet_id, PxPosition::new(Px(0), Px(y)));
359}
360
361fn render_content(
362 style: BottomSheetStyle,
363 bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
364) {
365 match style {
366 BottomSheetStyle::Glass => {
367 fluid_glass(
368 FluidGlassArgsBuilder::default()
369 .shape(Shape::RoundedRectangle {
370 top_left: Dp(50.0),
371 top_right: Dp(50.0),
372 bottom_right: Dp(0.0),
373 bottom_left: Dp(0.0),
374 g2_k_value: 3.0,
375 })
376 .tint_color(Color::new(0.6, 0.8, 1.0, 0.3)) // Give it a slight blue tint
377 .width(DimensionValue::Fill {
378 min: None,
379 max: None,
380 })
381 .refraction_amount(25.0)
382 .padding(Dp(20.0))
383 .blur_radius(10.0)
384 .block_input(true)
385 .build()
386 .unwrap(),
387 None,
388 bottom_sheet_content,
389 );
390 }
391 BottomSheetStyle::Material => {
392 surface(
393 SurfaceArgsBuilder::default()
394 .style(Color::new(0.2, 0.2, 0.2, 1.0).into())
395 .shape(Shape::RoundedRectangle {
396 top_left: Dp(25.0),
397 top_right: Dp(25.0),
398 bottom_right: Dp(0.0),
399 bottom_left: Dp(0.0),
400 g2_k_value: 3.0,
401 })
402 .width(DimensionValue::Fill {
403 min: None,
404 max: None,
405 })
406 .padding(Dp(20.0))
407 .block_input(true)
408 .build()
409 .unwrap(),
410 None,
411 bottom_sheet_content,
412 );
413 }
414 }
415}
416
417/// Renders a bottom sheet UI group, managing its animation, scrim, and content.
418///
419/// This is the main function for creating a bottom sheet. It should be called within a
420/// component that manages the application's state.
421///
422/// # Arguments
423///
424/// - `args`: Configuration options, including the style and close request handler.
425/// See [`BottomSheetProviderArgs`].
426/// - `state`: The shared state object that controls whether the sheet is open or closed.
427/// See [`BottomSheetProviderState`].
428/// - `main_content`: A closure that renders the primary UI content, which is always visible
429/// behind the sheet.
430/// - `bottom_sheet_content`: A closure that renders the content of the sheet itself. It
431/// receives a `f32` argument representing the current animation progress (alpha),
432/// which can be used to fade content in and out.
433#[tessera]
434pub fn bottom_sheet_provider(
435 args: BottomSheetProviderArgs,
436 state: Arc<RwLock<BottomSheetProviderState>>,
437 main_content: impl FnOnce() + Send + Sync + 'static,
438 bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
439) {
440 // Render main content first.
441 main_content();
442
443 // Snapshot state once to minimize locking overhead.
444 let (is_open, timer_opt) = snapshot_state(&state);
445
446 // Fast exit when nothing to render.
447 if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
448 return;
449 }
450
451 // Prepare values used by rendering and placement.
452 let on_close_for_keyboard = args.on_close_request.clone();
453 let progress = calc_progress_from_timer(timer_opt.as_ref());
454
455 // Render the configured scrim.
456 render_scrim(&args, progress, is_open);
457
458 // Register keyboard handler (close on Escape).
459 let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
460 input_handler(keyboard_closure);
461
462 // Render bottom sheet content.
463 render_content(args.style, bottom_sheet_content);
464
465 // Measurement: place main content, scrim and bottom sheet.
466 let state_for_measure = state.clone();
467 let measure_closure = Box::new(move |input: &tessera_ui::MeasureInput<'_>| {
468 // Place main content at origin.
469 let main_content_id = input.children_ids[0];
470 let main_content_size = input.measure_child(main_content_id, input.parent_constraint)?;
471 input.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
472
473 // Place scrim (if present) covering the whole parent.
474 if input.children_ids.len() > 1 {
475 let scrim_id = input.children_ids[1];
476 input.measure_child(scrim_id, input.parent_constraint)?;
477 input.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
478 }
479
480 // Place bottom sheet (if present) using extracted helper.
481 place_bottom_sheet_if_present(input, &state_for_measure, progress);
482
483 // Return the main content size (best-effort; unwrap used above to satisfy closure type).
484 Ok(main_content_size)
485 });
486 measure(measure_closure);
487}