1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum BottomSheetExpansion {
53 #[default]
56 PartiallyExpanded,
57 Expanded,
59}
60
61#[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 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 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 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 pub fn is_open(&self) -> bool {
125 self.is_open
126 }
127
128 pub fn expansion(&self) -> BottomSheetExpansion {
130 self.expansion
131 }
132
133 pub fn expand(&mut self) {
135 self.expansion = BottomSheetExpansion::Expanded;
136 self.resize.snap(1.0);
137 }
138
139 pub fn partially_expand(&mut self) {
141 self.expansion = BottomSheetExpansion::PartiallyExpanded;
142 self.resize.snap(0.0);
143 }
144
145 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 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
279fn 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
297fn 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
324fn 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 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#[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 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 let (is_open, visibility, drag_offset, resize_fraction) = controller.with(|c| c.snapshot());
805 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 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;