-
Notifications
You must be signed in to change notification settings - Fork 32
Open
Labels
Description
A bottom sheet component for Dioxus would be nice, quite commonly used nowadays in applications, and is quite a performance-critical part for the drag functionality:
known issues
- Well working handling of touch and mouse events
- Transitions from drag to click on child elements ( after stopping the drag, there is a delay before the child-elements are clickable )
- Internal scrollable elements ( carousels etc. ),
- Snap-Points either to complete internal component sizes ( Example having 3 child divs and having it snap between them on drag )
We have tried implementing this, but its using BeerCSS classes and forcing internal divs for snap-points, it works quite well, but there are always issues and optimizations, so this is something that would benefit from a group effort I believe
Example from google maps

Example implementation
This is an implementation for our specific usecase,where we have three states, minimal, middle, and fullscreen
mostly written by @oscrim, in case it helps anybody
use dioxus::{html::geometry::euclid::Size2D, prelude::*, web::WebEventExt};
use dioxus_logger::tracing::info;
use wasm_bindgen::JsCast;
use web_sys::HtmlElement;
fn empty_element() -> Element {
rsx! {}
}
#[derive(Props, Clone, PartialEq)]
pub struct BottomSheetProps {
/// Maximum height as percentage of viewport
#[props(default = 90.0)]
pub max_height: f64,
/// Set the snapping point for the bottom sheet, this will set the snapping point
/// when the bottom sheet is first rendered and then whenever the signal changes.
pub set_snapping_point: Option<ReadOnlySignal<BottomSheetSnappingPoint>>,
/// Content to render inside the bottom sheet when minimized
#[props(default = empty_element())]
pub minimized_children: Element,
/// Content that will be shown when at or above the middle height
#[props(default)]
pub middle_children: Option<Element>,
/// Content to render inside the bottom sheet
pub children: Element,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BottomSheetSnappingPoint {
Small,
Middle,
Large,
}
impl BottomSheetSnappingPoint {
/// Using the current height, minimum height, middle height, and maximum height, calculate the appropriate snapping point for the bottom sheet.
fn calculate(
current_height: f64,
min_height: f64,
middle_height: Option<f64>,
max_height: f64,
) -> Self {
let min = min_height;
let middle = middle_height;
let max = max_height;
let diff_to_min = (current_height - min).abs();
let diff_to_middle = middle.map(|middle| (current_height - middle).abs());
let diff_to_max = (current_height - max).abs();
let mut smallest = diff_to_min.min(diff_to_max);
if let Some(diff_to_middle) = diff_to_middle {
smallest = smallest.min(diff_to_middle);
}
if smallest == diff_to_min {
BottomSheetSnappingPoint::Small
} else if Some(smallest) == diff_to_middle {
BottomSheetSnappingPoint::Middle
} else {
BottomSheetSnappingPoint::Large
}
}
/// Set the size as the next size up from the current size
fn up(&mut self, has_middle: bool) {
*self = match self {
BottomSheetSnappingPoint::Small => {
if has_middle {
BottomSheetSnappingPoint::Middle
} else {
BottomSheetSnappingPoint::Large
}
}
BottomSheetSnappingPoint::Middle => BottomSheetSnappingPoint::Large,
BottomSheetSnappingPoint::Large => BottomSheetSnappingPoint::Large,
};
}
/// Set the size as the next size down from the current size
fn down(&mut self, has_middle: bool) {
*self = match self {
BottomSheetSnappingPoint::Small => BottomSheetSnappingPoint::Small,
BottomSheetSnappingPoint::Middle => BottomSheetSnappingPoint::Small,
BottomSheetSnappingPoint::Large => {
if has_middle {
BottomSheetSnappingPoint::Middle
} else {
BottomSheetSnappingPoint::Small
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DragDirection {
/// Only been dragging up
Up,
/// Only been dragging down
Down,
/// Both up and down
Both,
/// Not dragging
None,
}
trait UpdateDragDirection {
fn update_direction(&mut self, current_height: f64, new_height: f64);
}
impl UpdateDragDirection for Signal<DragDirection> {
fn update_direction(&mut self, current_height: f64, new_height: f64) {
let current_dir = *self.read();
match current_dir {
DragDirection::Both => {}
DragDirection::Up => {
if current_height > new_height {
// Started dragging down
self.set(DragDirection::Both);
}
}
DragDirection::Down => {
if current_height < new_height {
// Started dragging up
self.set(DragDirection::Both);
}
}
DragDirection::None => {
if current_height > new_height {
// Started dragging down
self.set(DragDirection::Down);
} else if current_height < new_height {
// Started dragging up
self.set(DragDirection::Up);
}
}
}
}
}
/// BottomSheet component that allows resizing and snapping to defined heights
#[component]
pub fn BottomSheet(props: BottomSheetProps) -> Element {
// The current height of the dialog
let mut dialog_height = use_signal(|| 0.0);
// Dragging state
let mut is_dragging = use_signal(|| false);
// Initial touch/drag coordinates
let mut start_y = use_signal(|| 0.0);
// Initial height when dragging starts
let mut start_height = use_signal(|| 0.0);
let mut current_state = use_signal(|| BottomSheetSnappingPoint::Small);
use_effect(move || {
if let Some(snapping_point_override) = props.set_snapping_point {
current_state.set(snapping_point_override());
}
});
let mut start_state = use_signal(|| BottomSheetSnappingPoint::Small);
// Scroll position (Pixels from the top of the content. 0 = at the top)
let mut content_scroll_top = use_signal(|| 0.0);
let mut drag_direction = use_signal(|| DragDirection::None);
let mut min_children_size = use_signal(|| None as Option<Size2D<f64, _>>);
let mut middle_children_size = use_signal(|| None as Option<Size2D<f64, _>>);
let min_height = use_memo(move || {
let size = min_children_size();
let Some(size) = size else {
return 0.0;
};
let Ok(Some(viewport_height)) = gloo_utils::window().inner_height().map(|h| h.as_f64())
else {
return 0.0;
};
let new_min_height = (size.height / viewport_height) * 100.0;
if new_min_height < 5.0 {
5.0
} else {
new_min_height.min(props.max_height)
}
});
let middle_height = use_memo(move || {
let min_height = min_height();
let size = middle_children_size()?;
let viewport_height = gloo_utils::window()
.inner_height()
.map(|h| h.as_f64())
.ok()
.flatten()?;
let new_middle_height = (size.height / viewport_height) * 100.0;
if new_middle_height < 5.0 {
None
} else {
// When showing middle we have to account for the min height as well
Some((new_middle_height + min_height).min(props.max_height))
}
});
// Helper function to find the closest snap point with directional intent
// This implements smart snapping that considers the user's drag direction:
// - If you drag significantly (>5%) toward another snap point, it commits to that direction
// - Small movements still use traditional closest-point snapping
// - Prevents annoying snap-back when clearly moving to the next level
let find_closest_snap_point = move |current_height: f64| -> BottomSheetSnappingPoint {
let start_state = start_state();
let start_h = start_height();
let drag_delta = current_height - start_h; // Positive = dragged up, negative = dragged down
let min = min_height();
let middle = middle_height();
let max = props.max_height;
let mut new_state = BottomSheetSnappingPoint::calculate(current_height, min, middle, max);
// 5% of viewport height threshold
const THRESHOLD: f64 = 5.0;
// If the sheet has been dragged more than the threshold, but no enough to change height state, snap to the next height state
let should_snap_to_next = start_state == new_state && drag_delta.abs() > THRESHOLD;
if should_snap_to_next && drag_direction() == DragDirection::Up {
// Dragging up (increasing height) - go to next higher snap point if available
new_state.up(middle.is_some());
} else if should_snap_to_next && drag_direction() == DragDirection::Down {
// Dragging down (decreasing height) - go to next lower snap point if available
new_state.down(middle.is_some());
};
new_state
};
let used_height = use_memo(move || {
if is_dragging() {
return dialog_height();
}
match current_state() {
BottomSheetSnappingPoint::Small => min_height(),
BottomSheetSnappingPoint::Middle => middle_height().unwrap_or(45.0),
BottomSheetSnappingPoint::Large => props.max_height,
}
});
// Whether the dialog is at maximum height
let at_max_height = use_memo(move || (used_height() - props.max_height).abs() < 1.0);
use_effect(move || {
// This is to update the height of the bottom sheet without triggering a re-render of this dioxus component
let root = gloo_utils::document_element();
let el = root.unchecked_into::<HtmlElement>();
let p = (used_height() / props.max_height) * 100.0;
// 100% means it is translated 100% of its own height downwards
let property = format!("calc(100% - {}%)", p);
el.style()
.set_property("--sheet-y-translation", &property)
.unwrap();
});
let mut reset_y_start = use_signal(|| false);
let mut on_move = move |current_y: f64| {
if is_dragging() {
let delta_y = start_y() - current_y;
if at_max_height() && delta_y > 0.0 {
return;
}
if at_max_height() && content_scroll_top() > 1.0 && delta_y < 0.0 {
return;
}
if reset_y_start() {
reset_y_start.set(false);
start_y.set(current_y);
return;
}
let window_height = web_sys::window()
.and_then(|w| w.inner_height().ok())
.and_then(|h| h.as_f64())
.unwrap_or(800.0);
let delta_percent = (delta_y / window_height) * 100.0;
let new_height = (start_height() + delta_percent)
.max(min_height())
.min(props.max_height);
drag_direction.update_direction(dialog_height(), new_height);
dialog_height.set(new_height);
}
};
let mut on_up = move || {
if is_dragging() {
let snapped_height = find_closest_snap_point(dialog_height());
current_state.set(snapped_height);
is_dragging.set(false);
drag_direction.set(DragDirection::None);
}
};
info!("Redrawing bottom sheet");
rsx! {
dialog {
class: "bottom active surface-container-low bottom-sheet resizable",
class: if is_dragging() { "resizing" },
class: if at_max_height() { "at-max-height" },
style: "height: calc({props.max_height}%); transform: translateY(calc(var(--sheet-y-translation)));",
onpointerdown: move |evt| {
let web_ev = evt.as_web_event();
let target = web_ev.target().unwrap().dyn_into::<web_sys::Element>().unwrap();
let pointer_id = evt.pointer_id();
target.set_pointer_capture(pointer_id).unwrap();
let used_height = used_height();
start_height.set(used_height);
dialog_height.set(used_height);
is_dragging.set(true);
reset_y_start.set(false);
start_y.set(evt.page_coordinates().y);
start_state.set(current_state());
},
onmousemove: move |evt| {
let current_y = evt.page_coordinates().y;
on_move(current_y);
},
ontouchmove: move |evt| {
let touches = evt.touches();
let Some(touch) = touches.get(0) else {
return;
};
let current_y = touch.page_coordinates().y;
on_move(current_y);
},
onmouseup: move |_evt| {
on_up();
},
ontouchend: move |_evt| {
on_up();
},
// Mouse wheel event for desktop scroll-to-resize functionality
onwheel: move |evt| {
let delta_y = evt.data().delta().strip_units().y;
let height_change = delta_y * -0.1;
let new_height = (dialog_height() - height_change)
.max(min_height())
.min(props.max_height);
if !at_max_height() {
evt.prevent_default();
dialog_height.set(new_height);
} else if content_scroll_top() == 0.0 && height_change > 0.0 {
evt.prevent_default();
dialog_height.set(new_height);
}
},
// Keyboard event handling - block certain keys when not at max height
onkeydown: move |evt| {
if (dialog_height() - props.max_height).abs() >= 1.0 {
let key = evt.key();
if matches!(
key,
Key::ArrowUp
| Key::ArrowDown
| Key::PageUp
| Key::PageDown
| Key::Home
| Key::End
) {
evt.prevent_default();
}
}
},
// Visual drag handle at the top of the dialog, only if resizable
if (props.max_height - min_height()).abs() > 1e-6 {
div { class: "handle absolute center top" }
}
// Main content area with scroll tracking
div {
class: "bottom-sheet-content",
// Track scroll position for advanced scroll/resize behavior
onscroll: move |_| {
if let Some(element) = gloo_utils::document()
.query_selector(".bottom-sheet-content")
.ok()
.flatten()
{
let new_scroll_top = element.scroll_top() as f64;
reset_y_start.set(true);
content_scroll_top.set(new_scroll_top);
}
},
div {
class: "bottom-sheet-header",
onresize: move |cx| {
let box_size = cx.get_border_box_size().ok();
min_children_size.set(box_size);
},
{props.minimized_children}
}
div {
onresize: move |cx| {
let box_size = cx.get_border_box_size().ok();
middle_children_size.set(box_size);
},
{props.middle_children}
}
{props.children}
}
}
}
}
snatvb and ealmloff