Skip to main content

tessera_components/
bottom_sheet.rs

1//! A component that displays content sliding up from the bottom of the screen.
2//!
3//! ## Usage
4//!
5//! Used to show contextual information or actions in a modal sheet.
6
7use std::time::Duration;
8
9use tessera_foundation::{
10    gesture::{DragRecognizer, TapRecognizer},
11    scroll_physics::ScrollVelocityTracker,
12};
13use tessera_ui::{
14    AxisConstraint, Callback, CallbackWith, Constraint, Dp, FocusProperties, FocusScopeNode,
15    FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition, RenderSlot,
16    State, current_frame_nanos, current_frame_time,
17    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
18    modifier::FocusModifierExt as _,
19    receive_frame_nanos, remember, remember_with_key, tessera,
20    time::Instant,
21    use_context, winit,
22};
23
24use crate::{
25    alignment::CrossAxisAlignment,
26    animation::{
27        material_motion::{Scalar, Spec},
28        sheet_drag::SheetDrag,
29        visibility_motion::{VisibilityMotion, VisibilitySample, modal_back_handler},
30    },
31    column::column,
32    modal_focus::{ModalFocusState, modal_focus_modifier},
33    modifier::{
34        ClickableArgs, InteractionState, ModifierExt, SemanticsArgs, with_keyboard_input,
35        with_pointer_input,
36    },
37    nested_scroll::{
38        NestedScrollConnection, PostScrollInput, PreFlingInput, PreScrollInput, ScrollDelta,
39        ScrollVelocity,
40    },
41    overlay_semantics::{dialog_semantics, overlay_background},
42    pos_misc::is_position_inside_bounds,
43    res::I18N,
44    shape_def::{RoundedCorner, Shape},
45    spacer::spacer,
46    surface::{SurfaceStyle, surface},
47    theme::MaterialTheme,
48};
49
50/// Height anchor of an open bottom sheet.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum BottomSheetExpansion {
53    /// Use the partial height, or the content's natural height when
54    /// unspecified.
55    #[default]
56    PartiallyExpanded,
57    /// Fill the height available below the sheet's top margin.
58    Expanded,
59}
60
61/// Controller for [`bottom_sheet_provider`], managing visibility and height.
62///
63/// This controller can be created by the application and passed through
64/// `bottom_sheet_provider().controller(...)`. It is used to control the
65/// visibility and selected height of the sheet programmatically.
66#[derive(Clone, PartialEq)]
67pub struct BottomSheetController {
68    is_open: bool,
69    motion: VisibilityMotion,
70    is_dragging: bool,
71    drag: SheetDrag,
72    expansion: BottomSheetExpansion,
73    resize: Scalar,
74    resize_origin: BottomSheetExpansion,
75    resize_range: Dp,
76    can_resize: bool,
77}
78
79impl BottomSheetController {
80    /// Creates a new controller.
81    pub fn new(initial_open: bool) -> Self {
82        Self {
83            is_open: initial_open,
84            motion: VisibilityMotion::new(initial_open, true),
85            is_dragging: false,
86            drag: SheetDrag::new(),
87            expansion: BottomSheetExpansion::PartiallyExpanded,
88            resize: Scalar::new(0.0),
89            resize_origin: BottomSheetExpansion::PartiallyExpanded,
90            resize_range: Dp::ZERO,
91            can_resize: true,
92        }
93    }
94
95    /// Initiates the animation to open the bottom sheet.
96    ///
97    /// If the sheet is already open, this has no effect. If the sheet is
98    /// currently closing, it will reverse direction and start opening from
99    /// its current position.
100    pub fn open(&mut self) {
101        if !self.is_open {
102            self.motion.retarget(true, current_frame_nanos());
103            self.is_open = true;
104            self.drag.settle(current_frame_nanos());
105        }
106    }
107
108    /// Initiates the animation to close the bottom sheet.
109    ///
110    /// If the sheet is already closed, this has no effect. If the sheet is
111    /// currently opening, it will reverse direction and start closing from
112    /// its current position.
113    pub fn close(&mut self) {
114        if self.is_open {
115            self.motion.retarget(false, current_frame_nanos());
116            self.is_open = false;
117            self.is_dragging = false;
118            self.drag.settle(current_frame_nanos());
119            self.resize.snap(self.expansion_fraction());
120        }
121    }
122
123    /// Returns whether the sheet is currently open.
124    pub fn is_open(&self) -> bool {
125        self.is_open
126    }
127
128    /// Returns the selected height anchor, including while the sheet is closed.
129    pub fn expansion(&self) -> BottomSheetExpansion {
130        self.expansion
131    }
132
133    /// Selects the expanded height without opening a closed sheet.
134    pub fn expand(&mut self) {
135        self.expansion = BottomSheetExpansion::Expanded;
136        self.resize.snap(1.0);
137    }
138
139    /// Selects the partial height without opening a closed sheet.
140    pub fn partially_expand(&mut self) {
141        self.expansion = BottomSheetExpansion::PartiallyExpanded;
142        self.resize.snap(0.0);
143    }
144
145    /// Switches between partial and expanded heights without changing
146    /// visibility.
147    pub fn toggle_expansion(&mut self) {
148        match self.expansion {
149            BottomSheetExpansion::PartiallyExpanded => self.expand(),
150            BottomSheetExpansion::Expanded => self.partially_expand(),
151        }
152    }
153
154    /// Returns whether the sheet is currently animating in either direction.
155    pub fn is_animating(&self) -> bool {
156        self.is_animating_at(current_frame_nanos())
157    }
158
159    fn is_animating_at(&self, frame: u64) -> bool {
160        let mut resize = self.resize;
161        resize.advance(frame);
162        self.motion.sample(frame).active
163            || (self.is_open && (self.drag.sample(frame).1 || resize.is_animating()))
164    }
165
166    fn snapshot(&self) -> (bool, VisibilitySample, f32, f64) {
167        let frame = current_frame_nanos();
168        let mut resize = self.resize;
169        resize.advance(frame);
170        (
171            self.is_open,
172            self.motion.sample(frame),
173            self.drag_offset(),
174            resize.value.clamp(0.0, 1.0),
175        )
176    }
177
178    fn resize_fraction(&self) -> f64 {
179        let mut resize = self.resize;
180        resize.advance(current_frame_nanos());
181        resize.value.clamp(0.0, 1.0)
182    }
183
184    fn expansion_fraction(&self) -> f64 {
185        match self.expansion {
186            BottomSheetExpansion::PartiallyExpanded => 0.0,
187            BottomSheetExpansion::Expanded => 1.0,
188        }
189    }
190
191    fn set_dragging(&mut self, dragging: bool) {
192        self.is_dragging = dragging;
193        if dragging {
194            let frame = current_frame_nanos();
195            self.drag.grab(frame);
196            self.resize.advance(frame);
197            self.resize.snap(self.resize.value.clamp(0.0, 1.0));
198            self.resize_origin = self.expansion;
199        }
200    }
201
202    fn apply_drag_delta(&mut self, delta_y: f32) -> f32 {
203        if !self.is_dragging {
204            self.set_dragging(true);
205        }
206        let frame = current_frame_nanos();
207        let mut remaining = delta_y;
208        let mut consumed = 0.0;
209        if remaining < 0.0 && self.drag_offset() > 0.0 {
210            let delta = self.drag.apply_delta(remaining, frame);
211            consumed += delta;
212            remaining -= delta;
213        }
214        if self.resize_range.0 > 0.0 {
215            self.resize.advance(frame);
216            let before = self.resize.value.clamp(0.0, 1.0);
217            let after =
218                (before - Dp::from_pixels_f32(remaining).0 / self.resize_range.0).clamp(0.0, 1.0);
219            self.resize.snap(after);
220            let delta = Dp((before - after) * self.resize_range.0).to_pixels_f32();
221            consumed += delta;
222            remaining -= delta;
223        }
224        if remaining > 0.0 {
225            consumed += self.drag.apply_delta(remaining, frame);
226        }
227        consumed
228    }
229
230    fn drag_offset(&self) -> f32 {
231        self.drag.sample(current_frame_nanos()).0
232    }
233
234    fn complete_drag(&mut self, velocity: f32) -> bool {
235        self.is_dragging = false;
236        let frame = current_frame_nanos();
237        self.resize.advance(frame);
238        if self.resize_range.0 > 0.0 && self.resize.value > 0.0 {
239            let velocity = Dp::from_pixels_f32(velocity).0;
240            let expanded = if velocity.abs() >= 125.0 {
241                velocity < 0.0
242            } else {
243                self.resize.value >= 0.5
244            };
245            self.expansion = if expanded {
246                BottomSheetExpansion::Expanded
247            } else {
248                BottomSheetExpansion::PartiallyExpanded
249            };
250            self.resize.velocity = -velocity / self.resize_range.0;
251            self.resize
252                .start_to(self.expansion_fraction(), Spec::Spatial, frame);
253            self.drag.settle(frame);
254            false
255        } else {
256            if self.resize_range.0 > 0.0 {
257                self.partially_expand();
258            }
259            self.drag.release(frame, velocity, 56.0, 125.0)
260        }
261    }
262
263    fn cancel_drag(&mut self) {
264        self.is_dragging = false;
265        self.expansion = self.resize_origin;
266        let frame = current_frame_nanos();
267        self.resize
268            .retarget(self.expansion_fraction(), Spec::Spatial, frame);
269        self.drag.settle(frame);
270    }
271}
272
273impl Default for BottomSheetController {
274    fn default() -> Self {
275        Self::new(false)
276    }
277}
278
279/// Compute Y position for bottom sheet placement.
280fn compute_bottom_sheet_y(
281    parent_height: Px,
282    child_height: Px,
283    progress: f32,
284    is_open: bool,
285    drag_offset: f32,
286) -> f32 {
287    let parent = parent_height.0 as f32;
288    let child = child_height.0 as f32;
289    let y = if is_open {
290        parent - child * progress
291    } else {
292        parent - child * (1.0 - progress)
293    };
294    y + drag_offset
295}
296
297/// Create the keyboard handler closure used to close the sheet on Escape.
298fn make_keyboard_closure(
299    on_close: Callback,
300) -> Box<dyn Fn(tessera_ui::KeyboardInput<'_>) + Send + Sync> {
301    Box::new(move |mut input: tessera_ui::KeyboardInput<'_>| {
302        let mut handled = false;
303        input.keyboard_events.retain(|event| {
304            if event.state != winit::event::ElementState::Pressed {
305                return true;
306            }
307
308            if let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
309                event.physical_key
310            {
311                on_close.call();
312                handled = true;
313                return false;
314            }
315
316            true
317        });
318        if handled {
319            input.block_keyboard();
320        }
321    })
322}
323
324/// Handle drag gestures on the bottom sheet.
325fn handle_drag_gestures(
326    controller: State<BottomSheetController>,
327    drag_recognizer: State<DragRecognizer>,
328    velocity_tracker: State<Option<ScrollVelocityTracker>>,
329    tap_recognizer: State<TapRecognizer>,
330    interaction: State<InteractionState>,
331    input: &mut tessera_ui::PointerInput<'_>,
332    on_close: &Callback,
333) {
334    let within_bounds = input
335        .cursor_position_rel
336        .map(|pos| is_position_inside_bounds(input.computed_data, pos))
337        .unwrap_or(false);
338    let now = current_frame_time().unwrap_or_else(Instant::now);
339    if within_bounds
340        && input.pointer_changes.iter().any(|change| {
341            !change.is_consumed()
342                && matches!(
343                    change.content,
344                    tessera_ui::CursorEventContent::Pressed(tessera_ui::PressKeyEventType::Left)
345                )
346        })
347    {
348        velocity_tracker.set(Some(ScrollVelocityTracker::new(
349            now,
350            Duration::from_millis(90),
351            Duration::from_millis(65),
352        )));
353    }
354    let (drag_result, cancelled) = drag_recognizer.with_mut(|recognizer| {
355        let was_dragging = recognizer.is_dragging();
356        let result = recognizer.update(
357            input.pass,
358            input.pointer_changes.as_mut_slice(),
359            within_bounds,
360        );
361        (
362            result,
363            was_dragging && !recognizer.is_dragging() && !result.ended,
364        )
365    });
366
367    if drag_result.started {
368        tap_recognizer.with_mut(TapRecognizer::cancel);
369        interaction.with_mut(InteractionState::release);
370        controller.with_mut(|c| c.set_dragging(true));
371    }
372
373    if drag_result.updated {
374        let consumed = controller.with_mut(|c| {
375            c.apply_drag_delta(input.local_delta(0.0, drag_result.delta_y.to_f32()).1)
376        });
377        velocity_tracker.with_mut(|tracker| {
378            if let Some(tracker) = tracker {
379                let window = input
380                    .local_to_window
381                    .map_vector(PxPosition::from_f32_xy(0.0, consumed));
382                tracker.push_delta(now, window.x, window.y);
383            }
384        });
385    }
386
387    if drag_result.ended {
388        let velocity = velocity_tracker.with_mut(|tracker| {
389            tracker
390                .take()
391                .and_then(|mut tracker| tracker.resolve(now))
392                .map(|v| v.y)
393                .unwrap_or(0.0)
394        });
395        let velocity = input.local_delta(0.0, velocity).1;
396        let should_close = controller.with_mut(|c| c.complete_drag(velocity));
397        if should_close {
398            on_close.call();
399        }
400    }
401    if cancelled {
402        velocity_tracker.set(None);
403        controller.with_mut(BottomSheetController::cancel_drag);
404    }
405}
406
407fn place_bottom_sheet_if_present(
408    input: &mut MeasureScope<'_>,
409    result: &mut LayoutResult,
410    is_open: bool,
411    drag_offset: f32,
412    progress: f32,
413    resize_fraction: f64,
414    partial_height: Option<Dp>,
415    controller: State<BottomSheetController>,
416    resize_range: State<Option<Dp>>,
417) {
418    let children = input.children();
419    if children.len() <= 2 {
420        return;
421    }
422
423    let bottom_sheet = children[2];
424
425    let parent_width = input
426        .parent_constraint()
427        .width()
428        .resolve_max()
429        .unwrap_or(Px(0));
430    let parent_height = input
431        .parent_constraint()
432        .height()
433        .resolve_max()
434        .unwrap_or(Px(0));
435
436    let max_width_px = Dp(640.0).to_px();
437    let is_large_screen = parent_width > max_width_px;
438
439    let sheet_width = if is_large_screen {
440        max_width_px.min((parent_width - Dp(112.0).to_px()).max(Px::ZERO))
441    } else {
442        parent_width
443    };
444
445    // M3 Spec: Top margin 56dp or 72dp.
446    let top_margin = if is_large_screen {
447        Dp(56.0).to_px()
448    } else {
449        Dp(72.0).to_px()
450    };
451    let max_height = (parent_height - top_margin).max(Px(0));
452
453    let constraint = Constraint::new(sheet_width, AxisConstraint::new(Px::ZERO, Some(max_height)));
454
455    let natural_size = match bottom_sheet.measure(input, &constraint) {
456        Ok(s) => s,
457        Err(_) => return,
458    };
459    let partial_height = partial_height
460        .map(|height| height.to_px().max(Dp(48.0).to_px()).min(max_height))
461        .unwrap_or(natural_size.height);
462    let range = Dp::from(max_height - partial_height);
463    if resize_range.get() != Some(range) {
464        resize_range.set(Some(range));
465        controller.with_mut(|controller| {
466            controller.resize_range = range;
467            controller.can_resize = range.0 > 0.0;
468        });
469    }
470    let height = partial_height
471        + Px::from_f32(((max_height - partial_height).to_f32() * resize_fraction as f32).round());
472    let child_size = if height == natural_size.height {
473        natural_size
474    } else {
475        match bottom_sheet.measure(
476            input,
477            &Constraint::new(sheet_width, AxisConstraint::exact(height)),
478        ) {
479            Ok(size) => size,
480            Err(_) => return,
481        }
482    };
483
484    let y = compute_bottom_sheet_y(
485        parent_height,
486        child_size.height,
487        progress,
488        is_open,
489        drag_offset,
490    );
491
492    let x = if is_large_screen {
493        (parent_width - child_size.width) / 2
494    } else {
495        Px(0)
496    };
497
498    result.place_child(bottom_sheet, PxPosition::from_f32_xy(x.0 as f32, y));
499}
500#[tessera]
501fn bottom_sheet_drag_handle(
502    controller: Option<State<BottomSheetController>>,
503    on_close: Option<Callback>,
504) {
505    let on_close = on_close.unwrap_or_default();
506    let controller = controller.expect("bottom_sheet_drag_handle requires controller");
507    let drag_recognizer = remember(DragRecognizer::default);
508    let velocity_tracker = remember(|| None::<ScrollVelocityTracker>);
509    let tap_recognizer = remember(TapRecognizer::default);
510    let interaction = remember(InteractionState::new);
511    let modifier = with_pointer_input(
512        Modifier::new().clickable_with(ClickableArgs {
513            enabled: controller.with(|controller| controller.can_resize),
514            label: Some(I18N.text("md3-drag-handle")),
515            block_input: false,
516            tap_recognizer: Some(tap_recognizer),
517            interaction_state: Some(interaction),
518            on_click: Callback::new(move || {
519                controller.with_mut(BottomSheetController::toggle_expansion)
520            }),
521            ..Default::default()
522        }),
523        move |mut input| {
524            handle_drag_gestures(
525                controller,
526                drag_recognizer,
527                velocity_tracker,
528                tap_recognizer,
529                interaction,
530                &mut input,
531                &on_close,
532            );
533        },
534    );
535
536    layout().modifier(modifier).child(move || {
537        column()
538            .modifier(Modifier::new().fill_max_width())
539            .cross_axis_alignment(CrossAxisAlignment::Center)
540            .children(move || {
541                spacer().modifier(Modifier::new().height(Dp(10.0)));
542                let scheme = use_context::<MaterialTheme>()
543                    .expect("MaterialTheme must be provided")
544                    .get()
545                    .color_scheme;
546                surface()
547                    .style(if interaction.with(InteractionState::is_focused) {
548                        SurfaceStyle::Outlined {
549                            color: scheme.secondary,
550                            width: Dp(2.0),
551                        }
552                    } else {
553                        tessera_ui::Color::TRANSPARENT.into()
554                    })
555                    .shape(Shape::CAPSULE)
556                    .modifier(Modifier::new().size(Dp(56.0), Dp(28.0)))
557                    .block_input(false)
558                    .content_alignment(crate::alignment::Alignment::Center)
559                    .child(move || {
560                        surface()
561                            .style(scheme.on_surface_variant.with_alpha(0.4).into())
562                            .shape(Shape::CAPSULE)
563                            .modifier(Modifier::new().size(Dp(32.0), Dp(4.0)))
564                            .block_input(false)
565                            .child(|| {});
566                    });
567                spacer().modifier(Modifier::new().height(Dp(10.0)));
568            });
569    });
570}
571
572fn build_bottom_sheet_nested_scroll_connection(
573    controller: State<BottomSheetController>,
574    on_close: Callback,
575    parent: Option<NestedScrollConnection>,
576) -> NestedScrollConnection {
577    NestedScrollConnection::new()
578        .with_pre_scroll_handler(CallbackWith::new({
579            move |input: PreScrollInput| {
580                if input.source != tessera_ui::ScrollEventSource::Touch
581                    || input.available.y >= 0.0
582                    || controller.with(|c| {
583                        c.drag_offset() <= 0.0
584                            && (c.resize_fraction() >= 1.0 || c.resize_range.0 <= 0.0)
585                    })
586                {
587                    return ScrollDelta::ZERO;
588                }
589
590                let consumed_y = controller.with_mut(|c| c.apply_drag_delta(input.available.y));
591                ScrollDelta::new(0.0, consumed_y)
592            }
593        }))
594        .with_post_scroll_handler(CallbackWith::new({
595            move |input: PostScrollInput| {
596                if input.source != tessera_ui::ScrollEventSource::Touch || input.available.y <= 0.0
597                {
598                    return ScrollDelta::ZERO;
599                }
600
601                let consumed_y = controller.with_mut(|c| c.apply_drag_delta(input.available.y));
602                ScrollDelta::new(0.0, consumed_y)
603            }
604        }))
605        .with_pre_fling_handler(CallbackWith::new({
606            move |input: PreFlingInput| {
607                if controller.with(|c| !c.is_dragging && c.drag_offset() <= 0.0) {
608                    return ScrollVelocity::ZERO;
609                }
610
611                let resizing =
612                    controller.with(|c| c.resize_range.0 > 0.0 && c.resize_fraction() > 0.0);
613                let should_close = controller.with_mut(|c| c.complete_drag(input.available.y));
614                if should_close {
615                    on_close.call();
616                }
617
618                ScrollVelocity::new(
619                    0.0,
620                    if resizing {
621                        input.available.y
622                    } else {
623                        input.available.y.max(0.0)
624                    },
625                )
626            }
627        }))
628        .with_parent(parent)
629}
630
631#[tessera]
632fn bottom_sheet_content_wrapper(
633    accessibility_label: Option<String>,
634    nested_scroll_connection: Option<NestedScrollConnection>,
635    bottom_sheet_content: Option<RenderSlot>,
636    controller: Option<State<BottomSheetController>>,
637    on_close: Option<Callback>,
638    just_opened: Option<bool>,
639    focus_scope: Option<FocusScopeNode>,
640) {
641    let on_close = on_close.unwrap_or_default();
642    let just_opened = just_opened.unwrap_or(false);
643    let controller = controller.expect("bottom_sheet_content_wrapper requires controller");
644    let bottom_sheet_content =
645        bottom_sheet_content.expect("bottom_sheet_content_wrapper requires sheet content");
646    let nested_scroll_connection =
647        nested_scroll_connection.expect("sheet content requires its nested scroll connection");
648    let focus_scope = focus_scope.expect("modal content requires its focus scope");
649    let modifier = dialog_semantics(
650        with_keyboard_input(
651            Modifier::new()
652                .focus_scope_with(focus_scope)
653                .focus_properties(FocusProperties::new().skip_traversal(true))
654                .focus_traversal_policy(
655                    FocusTraversalPolicy::linear()
656                        .wrap(true)
657                        .tab_navigation(true),
658                ),
659            make_keyboard_closure(on_close),
660        ),
661        accessibility_label,
662        true,
663    );
664    if just_opened {
665        focus_scope.request_focus();
666    }
667    layout().modifier(modifier).child(move || {
668        let bottom_sheet_content = bottom_sheet_content;
669        let nested_scroll_connection = nested_scroll_connection.clone();
670        let content_wrapper = RenderSlot::new(move || {
671            let bottom_sheet_content = bottom_sheet_content;
672            let nested_scroll_connection = nested_scroll_connection.clone();
673            column()
674                .modifier(
675                    Modifier::new()
676                        .fill_max_width()
677                        .nested_scroll(nested_scroll_connection.clone()),
678                )
679                .cross_axis_alignment(CrossAxisAlignment::Center)
680                .children(move || {
681                    bottom_sheet_drag_handle()
682                        .controller(controller)
683                        .on_close_shared(on_close);
684
685                    bottom_sheet_content.render();
686                });
687        });
688        surface()
689            .style(
690                use_context::<MaterialTheme>()
691                    .expect("MaterialTheme must be provided")
692                    .get()
693                    .color_scheme
694                    .surface_container_low
695                    .into(),
696            )
697            .shape(Shape::RoundedRectangle {
698                top_left: RoundedCorner::manual(Dp(28.0), 3.0),
699                top_right: RoundedCorner::manual(Dp(28.0), 3.0),
700                bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
701                bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
702            })
703            .modifier(Modifier::new().fill_max_width().on_layout_coordinates(
704                move |coordinates: tessera_ui::LayoutCoordinates| {
705                    let extent = Dp::from_pixels_f64(f64::from(coordinates.local_size.height.0)).0;
706                    if controller.with(|c| c.drag.extent() != extent) {
707                        controller.with_mut(|c| c.drag.set_extent(extent, current_frame_nanos()));
708                    }
709                },
710            ))
711            .block_input(true)
712            .child_shared(content_wrapper);
713    });
714}
715
716/// # bottom_sheet_provider
717///
718/// Provides a modal bottom sheet for contextual actions or information.
719///
720/// ## Usage
721///
722/// Show contextual menus, supplemental information, or simple forms without
723/// navigating away from the main screen.
724///
725/// ## Parameters
726///
727/// - `accessibility_label` — accessible name describing the dialog or sheet.
728/// - `on_close_request` — optional callback invoked when the sheet requests
729///   closing.
730/// - `is_open` — declarative open state.
731/// - `controller` — optional external controller for programmatic open/close.
732/// - `partial_height` — optional partial height, including the drag handle;
733///   defaults to the content's natural height. It is limited to the available
734///   height and is at least 48dp when that space is available.
735/// - `main_content` — optional main content rendered behind the sheet.
736/// - `bottom_sheet_content` — optional content rendered inside the sheet.
737///
738/// ## Examples
739///
740/// ```
741/// # use tessera_ui::tessera;
742/// # #[tessera]
743/// # fn component() {
744/// use tessera_components::bottom_sheet::bottom_sheet_provider;
745/// # use tessera_components::theme::{MaterialTheme, material_theme};
746///
747/// # material_theme()
748/// #     .theme(|| MaterialTheme::default())
749/// #     .child(|| {
750/// bottom_sheet_provider()
751///     .accessibility_label("Additional options")
752///     .on_close_request(|| {})
753///     .is_open(true)
754///     .partial_height(tessera_ui::Dp(240.0))
755///     .main_content(|| { /* main content */ })
756///     .bottom_sheet_content(|| { /* bottom sheet content */ });
757/// #     });
758/// # }
759/// # tessera_ui::testing::layout_test(component).run();
760/// ```
761#[tessera]
762pub fn bottom_sheet_provider(
763    #[prop(into)] accessibility_label: Option<String>,
764    on_close_request: Option<Callback>,
765    is_open: Option<bool>,
766    controller: Option<State<BottomSheetController>>,
767    partial_height: Option<Dp>,
768    main_content: Option<RenderSlot>,
769    bottom_sheet_content: Option<RenderSlot>,
770) {
771    let on_close_request = on_close_request.unwrap_or_default();
772    let is_open = is_open.unwrap_or(false);
773    let partial_height = partial_height.filter(|height| height.0.is_finite());
774    let main_content = main_content.unwrap_or_else(RenderSlot::empty);
775    let bottom_sheet_content = bottom_sheet_content.unwrap_or_else(RenderSlot::empty);
776    let external_controller = controller;
777    let controller = match external_controller {
778        Some(value) => value,
779        None => remember(|| BottomSheetController::new(is_open)),
780    };
781    let parent_nested_scroll = NestedScrollConnection::nearest();
782    let coordinate_scale = remember(|| 1.0_f32);
783    let nested_scroll_connection = build_bottom_sheet_nested_scroll_connection(
784        controller,
785        on_close_request,
786        parent_nested_scroll,
787    )
788    .with_coordinate_scale(coordinate_scale);
789
790    // In controlled mode (external controller provided), do not override
791    // controller state from `is_open`.
792    if external_controller.is_none() {
793        let current_open = controller.with(|c| c.is_open());
794        if is_open != current_open {
795            if is_open {
796                controller.with_mut(|c| c.open());
797            } else {
798                controller.with_mut(|c| c.close());
799            }
800        }
801    }
802
803    // Snapshot state to minimize locking overhead.
804    let (is_open, visibility, drag_offset, resize_fraction) = controller.with(|c| c.snapshot());
805    // Separate anchor feedback keeps dismissal state out of measurement
806    // dependencies.
807    let resize_range = remember_with_key(controller, || None::<Dp>);
808    let is_animating = controller.with(|c| c.is_animating());
809    let bottom_sheet_open_state = remember(|| false);
810    let mut just_opened = false;
811    bottom_sheet_open_state.with_mut(|was_open| {
812        just_opened = !*was_open && is_open;
813        *was_open = is_open;
814    });
815    if is_animating {
816        receive_frame_nanos(move |frame_nanos| {
817            let is_animating =
818                controller.with(|controller| controller.is_animating_at(frame_nanos));
819            if is_animating {
820                tessera_ui::FrameNanosControl::Continue
821            } else {
822                tessera_ui::FrameNanosControl::Stop
823            }
824        });
825    }
826
827    // The sheet overlay is gated by open state, but `main_content` must keep
828    // rendering: providers host the rest of the page inside it.
829    let show_bottom_sheet = is_open || is_animating;
830    let focus_scope = remember(FocusScopeNode::new).get();
831    let focus_state = remember(ModalFocusState::default);
832    let modifier =
833        modal_focus_modifier(Modifier::new(), focus_state, show_bottom_sheet, focus_scope);
834
835    let progress = if is_open {
836        visibility.position
837    } else {
838        1.0 - visibility.position
839    };
840
841    layout()
842        .modifier(
843            modifier.on_local_to_window(move |transform: tessera_ui::LocalToWindow| {
844                if coordinate_scale.get() != transform.scale() {
845                    coordinate_scale.set(transform.scale());
846                }
847            }),
848        )
849        .policy(BottomSheetLayout {
850            progress,
851            is_open,
852            drag_offset,
853            resize_fraction,
854            partial_height,
855            controller,
856            resize_range,
857        })
858        .child(move || {
859            let bottom_sheet_content = bottom_sheet_content;
860            overlay_background(main_content, show_bottom_sheet);
861            modal_back_handler(is_open, on_close_request).on_preview(move |event| {
862                controller.with_mut(|controller| {
863                    controller.motion.preview_back(event, current_frame_nanos());
864                });
865            });
866
867            if !show_bottom_sheet {
868                return;
869            }
870
871            let scrim_alpha = visibility.opacity * 0.32;
872            let scrim_color = use_context::<MaterialTheme>()
873                .expect("MaterialTheme must be provided")
874                .get()
875                .color_scheme
876                .scrim;
877            surface()
878                .style(scrim_color.with_alpha(scrim_alpha).into())
879                .on_click_shared(on_close_request)
880                .show_ripple(false)
881                .show_state_layer(false)
882                .modifier(
883                    Modifier::new()
884                        .fill_max_size()
885                        .semantics(SemanticsArgs {
886                            hidden: true,
887                            ..Default::default()
888                        })
889                        .focusable()
890                        .focus_properties(
891                            FocusProperties::new()
892                                .can_focus(false)
893                                .can_request_focus(false)
894                                .skip_traversal(true),
895                        ),
896                )
897                .block_input(true)
898                .child(|| {});
899
900            bottom_sheet_content_wrapper()
901                .nested_scroll_connection(nested_scroll_connection.clone())
902                .accessibility_label_optional(accessibility_label.clone())
903                .focus_scope(focus_scope)
904                .bottom_sheet_content_shared(bottom_sheet_content)
905                .controller(controller)
906                .on_close_shared(on_close_request)
907                .just_opened(just_opened);
908        });
909}
910
911#[derive(Clone, PartialEq)]
912struct BottomSheetLayout {
913    progress: f32,
914    is_open: bool,
915    drag_offset: f32,
916    resize_fraction: f64,
917    partial_height: Option<Dp>,
918    controller: State<BottomSheetController>,
919    resize_range: State<Option<Dp>>,
920}
921
922impl tessera_ui::LayoutModifierNode for BottomSheetLayout {
923    fn measure_eq(&self, other: &Self) -> bool {
924        self.resize_fraction == other.resize_fraction
925            && self.partial_height == other.partial_height
926            && self.resize_range == other.resize_range
927    }
928
929    fn place_children(
930        &self,
931        input: &PlacementScope<'_>,
932        _inner: &PlacementResult,
933    ) -> Option<PlacementResult> {
934        let children = input.children();
935        let main_content = children.first()?;
936        let mut result = LayoutResult::new(input.size());
937        result.place_child(*main_content, PxPosition::ZERO);
938        if let Some(scrim) = children.get(1) {
939            result.place_child(*scrim, PxPosition::ZERO);
940        }
941        if let Some(sheet) = children.get(2) {
942            let parent_width = input
943                .parent_constraint()
944                .width()
945                .resolve_max()
946                .unwrap_or(Px::ZERO);
947            let parent_height = input
948                .parent_constraint()
949                .height()
950                .resolve_max()
951                .unwrap_or(Px::ZERO);
952            let size = sheet.size();
953            let x = if parent_width >= Dp(640.0).to_px() {
954                (parent_width - size.width) / 2
955            } else {
956                Px::ZERO
957            };
958            let y = compute_bottom_sheet_y(
959                parent_height,
960                size.height,
961                self.progress,
962                self.is_open,
963                self.drag_offset,
964            );
965            result.place_child(*sheet, PxPosition::from_f32_xy(x.0 as f32, y));
966        }
967        Some(result.into_placement_result())
968    }
969
970    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
971        let mut result = LayoutResult::default();
972        let children = input.children();
973        let child_constraint = input.parent_constraint().without_min();
974        let main_content = children[0];
975        let main_content_size = main_content.measure(input, &child_constraint)?;
976        result.place_child(main_content, PxPosition::new(Px(0), Px(0)));
977
978        if children.len() > 1 {
979            let scrim = children[1];
980            scrim.measure(input, &child_constraint)?;
981            result.place_child(scrim, PxPosition::new(Px(0), Px(0)));
982        }
983
984        place_bottom_sheet_if_present(
985            input,
986            &mut result,
987            self.is_open,
988            self.drag_offset,
989            self.progress,
990            self.resize_fraction,
991            self.partial_height,
992            self.controller,
993            self.resize_range,
994        );
995
996        Ok(result.with_size(main_content_size.size()))
997    }
998}
999
1000#[cfg(test)]
1001mod placement_tests {
1002    use tessera_ui::Px;
1003
1004    use super::compute_bottom_sheet_y;
1005
1006    #[test]
1007    fn reversing_directions_keeps_fractional_sheet_placement_and_drag_offsets() {
1008        for (open, progress) in [(true, 0.3125), (false, 0.6875)] {
1009            assert_eq!(
1010                compute_bottom_sheet_y(Px(600), Px(120), progress, open, 4.25),
1011                566.75
1012            );
1013        }
1014    }
1015}
1016
1017#[cfg(test)]
1018#[path = "bottom_sheet_tests.rs"]
1019mod accessibility_tests;