Skip to content

Feature Request: BottomSheet/Drawer #122

@TotalKrill

Description

@TotalKrill

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

Image

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}
            }
        }
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions