use std::{
sync::Arc,
time::{Duration, Instant},
};
use derive_setters::Setters;
use tessera_ui::{
Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, MeasurementError,
Modifier, PressKeyEventType, Px, PxPosition, State,
layout::{LayoutInput, LayoutOutput, LayoutSpec},
remember, tessera, use_context, winit,
};
use crate::{
alignment::CrossAxisAlignment,
animation,
column::{ColumnArgs, column},
fluid_glass::{FluidGlassArgs, fluid_glass},
modifier::ModifierExt,
shape_def::{RoundedCorner, Shape},
spacer::spacer,
surface::{SurfaceArgs, surface},
theme::MaterialTheme,
};
const ANIM_TIME: Duration = Duration::from_millis(300);
#[derive(Default, Clone, Copy)]
pub enum BottomSheetStyle {
Glass,
#[default]
Material,
}
#[derive(Setters)]
pub struct BottomSheetProviderArgs {
#[setters(skip)]
pub on_close_request: Arc<dyn Fn() + Send + Sync>,
pub style: BottomSheetStyle,
pub is_open: bool,
}
impl BottomSheetProviderArgs {
pub fn new(on_close_request: impl Fn() + Send + Sync + 'static) -> Self {
Self {
on_close_request: Arc::new(on_close_request),
style: BottomSheetStyle::default(),
is_open: false,
}
}
pub fn on_close_request<F>(mut self, on_close_request: F) -> Self
where
F: Fn() + Send + Sync + 'static,
{
self.on_close_request = Arc::new(on_close_request);
self
}
pub fn on_close_request_shared(
mut self,
on_close_request: Arc<dyn Fn() + Send + Sync>,
) -> Self {
self.on_close_request = on_close_request;
self
}
}
#[derive(Clone)]
pub struct BottomSheetController {
is_open: bool,
timer: Option<Instant>,
is_dragging: bool,
drag_offset: f32,
drag_start_y: f32,
}
impl BottomSheetController {
pub fn new(initial_open: bool) -> Self {
Self {
is_open: initial_open,
timer: None,
is_dragging: false,
drag_offset: 0.0,
drag_start_y: 0.0,
}
}
pub fn open(&mut self) {
if !self.is_open {
self.is_open = true;
self.drag_offset = 0.0;
let mut timer = Instant::now();
if let Some(old_timer) = self.timer {
let elapsed = old_timer.elapsed();
if elapsed < ANIM_TIME {
timer += ANIM_TIME - elapsed;
}
}
self.timer = Some(timer);
}
}
pub fn close(&mut self) {
if self.is_open {
self.is_open = false;
let mut timer = Instant::now();
if let Some(old_timer) = self.timer {
let elapsed = old_timer.elapsed();
if elapsed < ANIM_TIME {
timer += ANIM_TIME - elapsed;
}
}
self.timer = Some(timer);
}
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn is_animating(&self) -> bool {
self.timer.is_some_and(|t| t.elapsed() < ANIM_TIME)
}
fn snapshot(&self) -> (bool, Option<Instant>, f32) {
(self.is_open, self.timer, self.drag_offset)
}
fn set_dragging(&mut self, dragging: bool) {
self.is_dragging = dragging;
}
fn update_drag_offset(&mut self, offset: f32) {
self.drag_offset = offset;
}
fn get_drag_offset(&self) -> f32 {
self.drag_offset
}
fn is_dragging(&self) -> bool {
self.is_dragging
}
fn set_drag_start_y(&mut self, y: f32) {
self.drag_start_y = y;
}
fn get_drag_start_y(&self) -> f32 {
self.drag_start_y
}
}
impl Default for BottomSheetController {
fn default() -> Self {
Self::new(false)
}
}
fn calc_progress_from_timer(timer: Option<&Instant>) -> f32 {
let raw = match timer {
None => 1.0,
Some(t) => {
let elapsed = t.elapsed();
if elapsed >= ANIM_TIME {
1.0
} else {
elapsed.as_secs_f32() / ANIM_TIME.as_secs_f32()
}
}
};
animation::easing(raw)
}
fn blur_radius_for(progress: f32, is_open: bool, max_blur_radius: f32) -> f32 {
if is_open {
progress * max_blur_radius
} else {
max_blur_radius * (1.0 - progress)
}
}
fn scrim_alpha_for(progress: f32, is_open: bool) -> f32 {
if is_open {
progress * 0.32
} else {
0.32 * (1.0 - progress)
}
}
fn compute_bottom_sheet_y(
parent_height: Px,
child_height: Px,
progress: f32,
is_open: bool,
drag_offset: f32,
) -> i32 {
let parent = parent_height.0 as f32;
let child = child_height.0 as f32;
let y = if is_open {
parent - child * progress
} else {
parent - child * (1.0 - progress)
};
(y + drag_offset) as i32
}
fn render_glass_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
let max_blur_radius = 5.0;
let blur_radius = blur_radius_for(progress, is_open, max_blur_radius);
fluid_glass(
FluidGlassArgs::default()
.on_click_shared(args.on_close_request.clone())
.tint_color(Color::TRANSPARENT)
.modifier(Modifier::new().fill_max_size())
.dispersion_height(Dp(0.0))
.refraction_height(Dp(0.0))
.block_input(true)
.blur_radius(Dp(blur_radius as f64))
.border(None)
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::manual(Dp(0.0), 3.0),
top_right: RoundedCorner::manual(Dp(0.0), 3.0),
bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
})
.noise_amount(0.0),
|| {},
);
}
fn render_material_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
let scrim_alpha = scrim_alpha_for(progress, is_open);
let scrim_color = use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme
.scrim;
surface(
SurfaceArgs::default()
.style(scrim_color.with_alpha(scrim_alpha).into())
.on_click_shared(args.on_close_request.clone())
.modifier(Modifier::new().fill_max_size())
.block_input(true),
|| {},
);
}
fn render_scrim(args: &BottomSheetProviderArgs, progress: f32, is_open: bool) {
match args.style {
BottomSheetStyle::Glass => render_glass_scrim(args, progress, is_open),
BottomSheetStyle::Material => render_material_scrim(args, progress, is_open),
}
}
fn make_keyboard_closure(
on_close: Arc<dyn Fn() + Send + Sync>,
) -> Box<dyn Fn(tessera_ui::InputHandlerInput<'_>) + Send + Sync> {
Box::new(move |input: tessera_ui::InputHandlerInput<'_>| {
for event in input.keyboard_events.drain(..) {
if event.state == winit::event::ElementState::Pressed
&& let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
event.physical_key
{
(on_close)();
}
}
})
}
fn handle_drag_gestures(
controller: State<BottomSheetController>,
input: &mut tessera_ui::InputHandlerInput<'_>,
on_close: &Arc<dyn Fn() + Send + Sync>,
) {
let mut is_dragging = controller.with(|c| c.is_dragging());
let drag_offset = controller.with(|c| c.get_drag_offset());
for event in input.cursor_events.iter() {
match &event.content {
CursorEventContent::Pressed(PressKeyEventType::Left) => {
if let Some(pos) = input.cursor_position_rel {
is_dragging = true;
controller.with_mut(|c| {
c.set_dragging(true);
c.set_drag_start_y(pos.y.0 as f32);
});
}
}
CursorEventContent::Released(PressKeyEventType::Left) => {
if is_dragging {
is_dragging = false;
controller.with_mut(|c| c.set_dragging(false));
if drag_offset > 100.0 {
(on_close)();
} else {
controller.with_mut(|c| c.update_drag_offset(0.0));
}
}
}
_ => {}
}
}
if is_dragging && let Some(pos) = input.cursor_position_rel {
let current_y = pos.y.0 as f32;
let start_y = controller.with(|c| c.get_drag_start_y());
let delta = current_y - start_y;
let new_offset = (drag_offset + delta).max(0.0);
if (new_offset - drag_offset).abs() > 0.001 {
controller.with_mut(|c| c.update_drag_offset(new_offset));
}
}
}
fn place_bottom_sheet_if_present(
input: &LayoutInput<'_>,
output: &mut LayoutOutput<'_>,
is_open: bool,
drag_offset: f32,
progress: f32,
) {
if input.children_ids().len() <= 2 {
return;
}
let bottom_sheet_id = input.children_ids()[2];
let parent_width = input.parent_constraint().width().get_max().unwrap_or(Px(0));
let parent_height = input
.parent_constraint()
.height()
.get_max()
.unwrap_or(Px(0));
let max_width_px = Dp(640.0).to_px();
let is_large_screen = parent_width >= max_width_px;
let sheet_width = if is_large_screen {
max_width_px
} else {
parent_width
};
let top_margin = if is_large_screen {
Dp(56.0).to_px()
} else {
Dp(72.0).to_px()
};
let max_height = (parent_height - top_margin).max(Px(0));
let constraint = Constraint {
width: DimensionValue::Fixed(sheet_width),
height: DimensionValue::Wrap {
min: None,
max: Some(max_height),
},
};
let child_size = match input.measure_child(bottom_sheet_id, &constraint) {
Ok(s) => s,
Err(_) => return,
};
let y = compute_bottom_sheet_y(
parent_height,
child_size.height,
progress,
is_open,
drag_offset,
);
let x = if is_large_screen {
(parent_width - child_size.width) / 2
} else {
Px(0)
};
output.place_child(bottom_sheet_id, PxPosition::new(x, Px(y)));
}
#[derive(Clone)]
struct DragHandlerArgs {
controller: State<BottomSheetController>,
on_close: Arc<dyn Fn() + Send + Sync>,
}
#[tessera]
fn drag_handler(args: DragHandlerArgs, child: impl FnOnce() + Send + Sync + 'static) {
let controller = args.controller;
let on_close = args.on_close;
input_handler(move |mut input| {
handle_drag_gestures(controller, &mut input, &on_close);
});
child();
}
fn render_content(
style: BottomSheetStyle,
bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
controller: State<BottomSheetController>,
on_close: Arc<dyn Fn() + Send + Sync>,
) {
let content_wrapper = move || {
drag_handler(
DragHandlerArgs {
controller,
on_close: on_close.clone(),
},
|| {
column(
ColumnArgs::default()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center),
|scope| {
scope.child(|| {
spacer(Modifier::new().height(Dp(22.0)));
});
scope.child(|| {
surface(
SurfaceArgs::default()
.style(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme
.on_surface_variant
.with_alpha(0.4)
.into(),
)
.shape(Shape::capsule())
.modifier(Modifier::new().size(Dp(32.0), Dp(4.0))),
|| {},
);
});
scope.child(|| {
spacer(Modifier::new().height(Dp(22.0)));
});
scope.child(bottom_sheet_content);
},
);
},
);
};
match style {
BottomSheetStyle::Glass => {
fluid_glass(
FluidGlassArgs::default()
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::manual(Dp(28.0), 3.0),
top_right: RoundedCorner::manual(Dp(28.0), 3.0),
bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
})
.tint_color(Color::WHITE.with_alpha(0.4))
.modifier(Modifier::new().fill_max_width())
.refraction_amount(32.0)
.blur_radius(Dp(5.0))
.block_input(true),
content_wrapper,
);
}
BottomSheetStyle::Material => {
surface(
SurfaceArgs::default()
.style(
use_context::<MaterialTheme>()
.expect("MaterialTheme must be provided")
.get()
.color_scheme
.surface_container_low
.into(),
)
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::manual(Dp(28.0), 3.0),
top_right: RoundedCorner::manual(Dp(28.0), 3.0),
bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
})
.modifier(Modifier::new().fill_max_width())
.block_input(true),
content_wrapper,
);
}
}
}
#[tessera]
pub fn bottom_sheet_provider(
args: impl Into<BottomSheetProviderArgs>,
main_content: impl FnOnce() + Send + Sync + 'static,
bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
) {
let args: BottomSheetProviderArgs = args.into();
let controller = remember(|| BottomSheetController::new(args.is_open));
let current_open = controller.with(|c| c.is_open());
if args.is_open != current_open {
if args.is_open {
controller.with_mut(|c| c.open());
} else {
controller.with_mut(|c| c.close());
}
}
bottom_sheet_provider_with_controller(args, controller, main_content, bottom_sheet_content);
}
#[tessera]
pub fn bottom_sheet_provider_with_controller(
args: impl Into<BottomSheetProviderArgs>,
controller: State<BottomSheetController>,
main_content: impl FnOnce() + Send + Sync + 'static,
bottom_sheet_content: impl FnOnce() + Send + Sync + 'static,
) {
let args: BottomSheetProviderArgs = args.into();
main_content();
let (is_open, timer_opt, drag_offset) = controller.with(|c| c.snapshot());
if !(is_open || timer_opt.is_some_and(|t| t.elapsed() < ANIM_TIME)) {
return;
}
let on_close_for_keyboard = args.on_close_request.clone();
let progress = calc_progress_from_timer(timer_opt.as_ref());
render_scrim(&args, progress, is_open);
let keyboard_closure = make_keyboard_closure(on_close_for_keyboard);
input_handler(keyboard_closure);
render_content(
args.style,
bottom_sheet_content,
controller,
args.on_close_request.clone(),
);
layout(BottomSheetLayout {
progress,
is_open,
drag_offset,
});
}
#[derive(Clone, PartialEq)]
struct BottomSheetLayout {
progress: f32,
is_open: bool,
drag_offset: f32,
}
impl LayoutSpec for BottomSheetLayout {
fn measure(
&self,
input: &LayoutInput<'_>,
output: &mut LayoutOutput<'_>,
) -> Result<ComputedData, MeasurementError> {
let main_content_id = input.children_ids()[0];
let main_content_size = input.measure_child_in_parent_constraint(main_content_id)?;
output.place_child(main_content_id, PxPosition::new(Px(0), Px(0)));
if input.children_ids().len() > 1 {
let scrim_id = input.children_ids()[1];
input.measure_child_in_parent_constraint(scrim_id)?;
output.place_child(scrim_id, PxPosition::new(Px(0), Px(0)));
}
place_bottom_sheet_if_present(input, output, self.is_open, self.drag_offset, self.progress);
Ok(main_content_size)
}
}