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}