yew_virtual_scroller/
lib.rs

1#![deny(missing_docs)]
2
3//! ![Crates.io](https://img.shields.io/crates/l/yew-virtual-scroller) ![Crates.io](https://img.shields.io/crates/v/yew-virtual-scroller)
4//!
5//! A Yew component for virtual scrolling / scroll windowing -- Only renders the visible content into the dom.
6//!
7//! # Example:
8//! ```rust
9//! struct MyItem { value: usize }
10//!
11//! impl From<MyItem> for yew::Html {
12//!     fn from(item: MyItem) -> Self {
13//!         html! {
14//!             // Each item must be the same height.
15//!             <div key={item.value} style="height: 32px;">
16//!                 {format!("Item: {}", item.value)}
17//!             </div>
18//!         }
19//!     }
20//! }
21//!
22//! fn view(&self) -> yew::Html {
23//!     // Items is wrapped with an Rc to avoid cloning large lists.
24//!     let items = Rc::clone(&self.items);
25//!     html! {
26//!         <div>
27//!             <style>{"
28//!                 /* Scroller should be constrained in some way so it can scroll */
29//!                 .scroller {
30//!                     height: 600px;
31//!                 }
32//!             "}</style>
33//!
34//!             <VirtualScroller<MyItem>
35//!                 items={items}
36//!                 row_height={32.0}
37//!                 class=Classes::from("scroller")
38//!             />
39//!         </div>
40//!     }
41//! }
42//! ```
43//!
44//! # License
45//!
46//! Licensed under either of
47//!
48//!  * Apache License, Version 2.0
49//!    ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
50//!  * MIT license
51//!    ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
52//!
53//! at your option.
54//!
55//! # Contribution
56//!
57//! Unless you explicitly state otherwise, any contribution intentionally submitted
58//! for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
59//! dual licensed as above, without any additional terms or conditions.
60
61use std::{
62    cmp::{max, min},
63    fmt::Debug,
64    ops::Range,
65    rc::Rc,
66};
67use web_sys::Element;
68use yew::{html, Classes, Component, NodeRef, Properties};
69use yew_component_size::{ComponentSize, ComponentSizeObserver};
70
71const WINDOW_STYLES: &str = "will-change:transform;";
72
73/// Yew component for virtual scrolling / scroll windowing
74///
75/// See the crate documentation for an example and more information.
76pub struct VirtualScroller<T>
77where
78    T: Into<yew::Html> + Clone + PartialEq + Debug + 'static,
79{
80    /// Component properties
81    pub props: Props<T>,
82
83    link: yew::ComponentLink<Self>,
84    viewport_ref: NodeRef,
85    viewport_height: f64,
86    content_window: Option<ContentWindow>,
87}
88
89/// VirtualScroller properties
90#[derive(Properties, Clone, PartialEq, Debug)]
91pub struct Props<T>
92where
93    T: Into<yew::Html> + Clone + PartialEq + Debug + 'static,
94{
95    /// Full list of items. This is within an Rc as the assumption is the list will be large
96    /// and so cloning it would be expensive.
97    pub items: Rc<Vec<T>>,
98
99    /// Height of each item in pixels.
100    pub row_height: f64,
101
102    /// Class(es) to apply to the root container
103    #[prop_or_default]
104    pub class: Classes,
105}
106
107#[doc(hidden)]
108pub enum Msg {
109    CalculateViewport,
110    UpdateViewportHeight(f64),
111    CalculateWindowContent,
112}
113
114struct ContentWindow {
115    start_y: f64,
116    visible_range: Range<usize>,
117}
118
119impl<T> Component for VirtualScroller<T>
120where
121    T: Into<yew::Html> + Clone + PartialEq + Debug + 'static,
122{
123    type Message = Msg;
124
125    type Properties = Props<T>;
126
127    fn create(props: Self::Properties, link: yew::ComponentLink<Self>) -> Self {
128        Self {
129            props,
130            link,
131            viewport_ref: Default::default(),
132            viewport_height: 0f64,
133            content_window: None,
134        }
135    }
136
137    fn update(&mut self, msg: Self::Message) -> yew::ShouldRender {
138        match msg {
139            Msg::CalculateViewport => {
140                let viewport = self.viewport_ref.cast::<Element>().unwrap();
141                self.viewport_height = viewport.client_height() as f64;
142                true
143            }
144            Msg::UpdateViewportHeight(height) => {
145                self.viewport_height = height;
146                true
147            }
148            Msg::CalculateWindowContent => {
149                let node_padding: usize = 0;
150                let viewport = self.viewport_ref.cast::<Element>().unwrap();
151                let scroll_top = viewport.scroll_top() as f64;
152                let start_node = max(
153                    0,
154                    ((scroll_top / self.props.row_height).floor() as isize)
155                        - (node_padding as isize),
156                ) as usize;
157                let total_visible = min(
158                    ((self.viewport_height / self.props.row_height).ceil()) as usize
159                        + 2 * node_padding,
160                    self.props.items.len() - start_node,
161                );
162                let start_y = (start_node as f64) * self.props.row_height;
163                let end_node = start_node + total_visible;
164                self.content_window = Some(ContentWindow {
165                    start_y,
166                    visible_range: start_node..end_node,
167                });
168                true
169            }
170        }
171    }
172
173    fn change(&mut self, props: Self::Properties) -> yew::ShouldRender {
174        if self.props != props {
175            let should_rerender = self.props.class != props.class;
176            self.props = props;
177            self.link.send_message(Msg::CalculateWindowContent);
178            should_rerender
179        } else {
180            false
181        }
182    }
183
184    fn view(&self) -> yew::Html {
185        let total_content_height = (self.props.items.len() as f64) * self.props.row_height;
186        let content_style = format!("height: {}px", total_content_height);
187
188        let (window_style, windowed_items) = match &self.content_window {
189            Some(cw) => (
190                format!("{}transform: translateY({}px);", WINDOW_STYLES, cw.start_y),
191                (&self.props.items[cw.visible_range.clone()]).into(),
192            ),
193            None => (WINDOW_STYLES.to_string(), vec![]),
194        };
195        let items = windowed_items.into_iter().map(|item| item.into());
196
197        let onscroll = self.link.callback(|_| Msg::CalculateWindowContent);
198        let onsize = self.link.batch_callback(|rect: ComponentSize| {
199            vec![
200                Msg::UpdateViewportHeight(rect.height),
201                Msg::CalculateWindowContent,
202            ]
203        });
204
205        html! {
206            <div ref=self.viewport_ref.clone() onscroll=onscroll class=self.props.class.clone() style="position: relative; overflow: auto">
207                <div style=content_style>
208                    <div style=window_style>
209                        {for items}
210                    </div>
211                </div>
212                <ComponentSizeObserver onsize=onsize />
213            </div>
214        }
215    }
216
217    fn rendered(&mut self, first_render: bool) {
218        if first_render {
219            self.link
220                .send_message_batch(vec![Msg::CalculateViewport, Msg::CalculateWindowContent]);
221        }
222    }
223}