diff --git a/README.md b/README.md index 90a8467..89cd37f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ fn counter() -> Html { - `use_default` - returns the default value when state is None. - `use_debounce_state` - debounces state. - `use_throttle_state` - throttles state. +- `use_virtual_list` - provides virtual scrolling for large lists to improve performance. ### Side-effects diff --git a/crates/yew-hooks/Cargo.toml b/crates/yew-hooks/Cargo.toml index be1db82..797d0e6 100644 --- a/crates/yew-hooks/Cargo.toml +++ b/crates/yew-hooks/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yew-hooks" -version = "0.4.0" +version = "0.4.1" edition = "2021" authors = ["Jet Li "] categories = ["gui", "wasm", "web-programming"] @@ -31,14 +31,17 @@ features = [ "Blob", "CloseEvent", "Coordinates", + "CssStyleDeclaration", "DataTransfer", "DataTransferItem", "DataTransferItemList", "DomRectReadOnly", "Element", + "Event", "File", "Geolocation", "HtmlCollection", + "HtmlElement", "HtmlLinkElement", "HtmlMediaElement", "IntersectionObserver", diff --git a/crates/yew-hooks/src/hooks/mod.rs b/crates/yew-hooks/src/hooks/mod.rs index 7674b45..86e3d26 100644 --- a/crates/yew-hooks/src/hooks/mod.rs +++ b/crates/yew-hooks/src/hooks/mod.rs @@ -51,6 +51,7 @@ mod use_title; mod use_toggle; mod use_unmount; mod use_update; +mod use_virtual_list; mod use_visible; mod use_websocket; mod use_window_scroll; @@ -109,6 +110,7 @@ pub use use_title::*; pub use use_toggle::*; pub use use_unmount::*; pub use use_update::*; +pub use use_virtual_list::*; pub use use_visible::*; pub use use_websocket::*; pub use use_window_scroll::*; diff --git a/crates/yew-hooks/src/hooks/use_virtual_list.rs b/crates/yew-hooks/src/hooks/use_virtual_list.rs new file mode 100644 index 0000000..eb89480 --- /dev/null +++ b/crates/yew-hooks/src/hooks/use_virtual_list.rs @@ -0,0 +1,246 @@ +use std::rc::Rc; +use wasm_bindgen::prelude::*; +use yew::prelude::*; + +/// State handle for the [`use_virtual_list`] hook. +#[derive(Clone, PartialEq)] +pub struct VirtualListItem { + /// The item data + pub data: T, + /// The index in the original list + pub index: usize, + /// The top position in pixels + pub top: f64, + /// The height of the item in pixels + pub height: f64, +} + +/// State handle for the [`use_virtual_list`] hook. +pub struct UseVirtualListHandle { + /// The visible items + pub visible_items: Vec>, + /// The total height of all items + pub total_height: f64, + /// The start index of visible items + pub start_index: usize, + /// The end index of visible items + pub end_index: usize, + /// Function to scroll to a specific index + pub scroll_to: Rc, +} + +impl Clone for UseVirtualListHandle +where + T: Clone, +{ + fn clone(&self) -> Self { + Self { + visible_items: self.visible_items.clone(), + total_height: self.total_height, + start_index: self.start_index, + end_index: self.end_index, + scroll_to: self.scroll_to.clone(), + } + } +} + +impl PartialEq for UseVirtualListHandle +where + T: PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + self.visible_items == other.visible_items + && self.total_height == other.total_height + && self.start_index == other.start_index + && self.end_index == other.end_index + } +} + +/// A hook that provides virtual scrolling for large lists. +/// +/// This hook calculates which items should be visible based on the scroll position +/// and container height, improving performance for large lists. +/// +/// # Example +/// +/// ```rust +/// # use yew::prelude::*; +/// # +/// use yew_hooks::prelude::*; +/// +/// #[function_component(VirtualList)] +/// fn virtual_list() -> Html { +/// let items = (0..10000).collect::>(); +/// let item_height = |index: usize| 50.0; +/// let container = use_node_ref(); +/// let wrapper = use_node_ref(); +/// let overscan = 5; +/// +/// let virtual_list = use_virtual_list( +/// items, +/// item_height, +/// container.clone(), +/// wrapper.clone(), +/// overscan, +/// ); +/// +/// html! { +/// <> +///
+///
+/// { +/// for virtual_list.visible_items.iter().map(|item| { +/// html! { +///
+/// { format!("Item {}", item.data) } +///
+/// } +/// }) +/// } +///
+///
+/// +/// +/// } +/// } +/// ``` +#[hook] +pub fn use_virtual_list( + items: Vec, + item_height: fn(usize) -> f64, + container: NodeRef, + wrapper: NodeRef, + overscan: usize, +) -> UseVirtualListHandle +where + T: Clone + PartialEq + 'static, +{ + let scroll_position = use_state(|| 0.0); + let container_height = use_state(|| 0.0); + let handle = use_state(|| UseVirtualListHandle { + visible_items: vec![], + total_height: 0.0, + start_index: 0, + end_index: 0, + scroll_to: Rc::new(|_| {}), + }); + + { + let items = items.clone(); + let scroll_top_val = *scroll_position; + let container_height_val = *container_height; + let handle_clone = handle.clone(); + let wrapper_clone = wrapper.clone(); + let scroll_position_clone = scroll_position.clone(); + let container_clone = container.clone(); + use_effect_with( + (items, container_height_val, scroll_top_val, overscan), + move |(items, container_height, scroll_top, overscan)| { + let heights: Vec = (0..items.len()).map(item_height).collect(); + let total_height = heights.iter().sum::(); + let mut cumulative = 0.0; + let mut start_index = 0; + for (i, &h) in heights.iter().enumerate() { + if cumulative + h > *scroll_top { + start_index = i; + break; + } + cumulative += h; + } + let start_cum = cumulative; + let mut end_index = start_index; + let mut current_cum = start_cum; + while current_cum < *scroll_top + *container_height && end_index < items.len() { + current_cum += heights[end_index]; + end_index += 1; + } + end_index = end_index.min(items.len()); + let start_index = start_index.saturating_sub(*overscan); + let end_index = (end_index + *overscan).min(items.len()); + let visible_items = (start_index..end_index) + .map(|index| { + let top = heights[0..index].iter().sum::(); + VirtualListItem { + data: items[index].clone(), + index, + top, + height: heights[index], + } + }) + .collect(); + let scroll_to = { + let heights = heights.clone(); + let st_setter = scroll_position_clone.clone(); + let container = container_clone.clone(); + Rc::new(move |index: usize| { + if index < heights.len() { + let top = heights[0..index].iter().sum::(); + st_setter.set(top); + if let Some(c) = container.get() { + if let Some(e) = c.dyn_ref::() { + e.set_scroll_top(top as i32); + } + } + } + }) + }; + let new_handle = UseVirtualListHandle { + visible_items, + total_height, + start_index, + end_index, + scroll_to, + }; + handle_clone.set(new_handle.clone()); + // Set height on wrapper + if let Some(w) = wrapper_clone.get() { + if let Some(e) = w.dyn_ref::() { + let _ = e + .style() + .set_property("height", &format!("{}px", total_height)); + let _ = e.style().set_property("position", "relative"); + } + } + }, + ); + } + + { + let container_clone = container.clone(); + let scroll_position_clone = scroll_position.clone(); + let container_height_clone = container_height.clone(); + use_effect_with(container_clone, move |container| { + if let Some(c) = container.get() { + let c = c.clone(); + if let Some(e) = c.dyn_ref::() { + container_height_clone.set(e.client_height() as f64); + scroll_position_clone.set(e.scroll_top() as f64); + let scroll_top_inner = scroll_position_clone.clone(); + let c_clone = c.clone(); + let closure = Closure::wrap(Box::new(move |_: web_sys::Event| { + if let Some(e) = c_clone.dyn_ref::() { + scroll_top_inner.set(e.scroll_top() as f64); + } + }) as Box); + let _ = e.add_event_listener_with_callback( + "scroll", + closure.as_ref().unchecked_ref(), + ); + closure.forget(); + } + } + || {} + }); + } + + (*handle).clone() +} diff --git a/examples/yew-app/src/app/home.rs b/examples/yew-app/src/app/home.rs index 3ea10bc..884e2a7 100644 --- a/examples/yew-app/src/app/home.rs +++ b/examples/yew-app/src/app/home.rs @@ -24,6 +24,7 @@ pub fn home() -> Html {
  • to={AppRoute::UseMutLatest} classes="text-emerald-800 underline">{ "use_mut_latest" }> { " - returns the latest mutable ref to state or props." }
  • to={AppRoute::UsePrevious} classes="text-emerald-800 underline">{ "use_previous" }> { " - returns the previous immutable ref to state or props." }
  • to={AppRoute::UseList} classes="text-emerald-800 underline">{ "use_list" }> { " - tracks state of a list." }
  • +
  • to={AppRoute::UseVirtualList} classes="text-emerald-800 underline">{ "use_virtual_list" }> { " - provides virtual scrolling for large lists." }
  • to={AppRoute::UseMap} classes="text-emerald-800 underline">{ "use_map" }> { " - tracks state of a hash map." }
  • to={AppRoute::UseSet} classes="text-emerald-800 underline">{ "use_set" }> { " - tracks state of a hash set." }
  • to={AppRoute::UseQueue} classes="text-emerald-800 underline">{ "use_queue" }> { " - tracks state of a queue." }
  • diff --git a/examples/yew-app/src/app/hooks/mod.rs b/examples/yew-app/src/app/hooks/mod.rs index 1afb7a9..a01e619 100644 --- a/examples/yew-app/src/app/hooks/mod.rs +++ b/examples/yew-app/src/app/hooks/mod.rs @@ -53,6 +53,7 @@ mod use_title; mod use_toggle; mod use_unmount; mod use_update; +mod use_virtual_list; mod use_visible; mod use_websocket; mod use_window_scroll; @@ -113,6 +114,7 @@ pub use use_title::*; pub use use_toggle::*; pub use use_unmount::*; pub use use_update::*; +pub use use_virtual_list::*; pub use use_visible::*; pub use use_websocket::*; pub use use_window_scroll::*; diff --git a/examples/yew-app/src/app/hooks/use_virtual_list.rs b/examples/yew-app/src/app/hooks/use_virtual_list.rs new file mode 100644 index 0000000..2750928 --- /dev/null +++ b/examples/yew-app/src/app/hooks/use_virtual_list.rs @@ -0,0 +1,60 @@ +use yew::prelude::*; +use yew_hooks::prelude::*; + +use crate::components::ui::button::Button; + +/// `use_virtual_list` demo +#[function_component] +pub fn UseVirtualList() -> Html { + let items = (0..10000).collect::>(); + let item_height = |_index: usize| 50.0; + let items_len = items.len(); + let container = use_node_ref(); + let wrapper = use_node_ref(); + let overscan = 5; + + let virtual_list = use_virtual_list( + items, + item_height, + container.clone(), + wrapper.clone(), + overscan, + ); + + html! { +
    +
    +
    +

    { format!("Total items: {}, Visible: {}-{}", items_len, virtual_list.start_index, virtual_list.end_index) }

    + +
    +
    + { + for virtual_list.visible_items.iter().map(|item| { + html! { +
    + { format!("Item {} (index: {})", item.data, item.index) } +
    + } + }) + } +
    +
    +
    +
    +
    + } +} diff --git a/examples/yew-app/src/app/mod.rs b/examples/yew-app/src/app/mod.rs index 5e2c76f..12c0329 100644 --- a/examples/yew-app/src/app/mod.rs +++ b/examples/yew-app/src/app/mod.rs @@ -129,6 +129,8 @@ pub enum AppRoute { UseInfiniteScroll, #[at("/use_visible")] UseVisible, + #[at("/use_virtual_list")] + UseVirtualList, #[at("/use_hovered")] UseHovered, #[at("/use_permission")] @@ -202,6 +204,7 @@ pub fn switch(routes: AppRoute) -> Html { AppRoute::UseClipboard => html! { }, AppRoute::UseInfiniteScroll => html! { }, AppRoute::UseVisible => html! { }, + AppRoute::UseVirtualList => html! { }, AppRoute::UseHovered => html! { }, AppRoute::UsePermission => html! { }, AppRoute::PageNotFound => html! { },