#![deny(missing_docs)]
use std::{
cmp::{max, min},
fmt::Debug,
ops::Range,
rc::Rc,
};
use web_sys::Element;
use yew::{html, Classes, Component, NodeRef, Properties};
use yew_component_size::{ComponentSize, ComponentSizeObserver};
const WINDOW_STYLES: &str = "will-change:transform;";
pub struct VirtualScroller<T>
where
T: Into<yew::Html> + Clone + PartialEq + Debug + 'static,
{
pub props: Props<T>,
link: yew::ComponentLink<Self>,
viewport_ref: NodeRef,
viewport_height: f64,
content_window: Option<ContentWindow>,
}
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct Props<T>
where
T: Into<yew::Html> + Clone + PartialEq + Debug + 'static,
{
pub items: Rc<Vec<T>>,
pub row_height: f64,
#[prop_or_default]
pub class: Classes,
}
#[doc(hidden)]
pub enum Msg {
CalculateViewport,
UpdateViewportHeight(f64),
CalculateWindowContent,
}
struct ContentWindow {
start_y: f64,
visible_range: Range<usize>,
}
impl<T> Component for VirtualScroller<T>
where
T: Into<yew::Html> + Clone + PartialEq + Debug + 'static,
{
type Message = Msg;
type Properties = Props<T>;
fn create(props: Self::Properties, link: yew::ComponentLink<Self>) -> Self {
Self {
props,
link,
viewport_ref: Default::default(),
viewport_height: 0f64,
content_window: None,
}
}
fn update(&mut self, msg: Self::Message) -> yew::ShouldRender {
match msg {
Msg::CalculateViewport => {
let viewport = self.viewport_ref.cast::<Element>().unwrap();
self.viewport_height = viewport.client_height() as f64;
true
}
Msg::UpdateViewportHeight(height) => {
self.viewport_height = height;
true
}
Msg::CalculateWindowContent => {
let node_padding: usize = 0;
let viewport = self.viewport_ref.cast::<Element>().unwrap();
let scroll_top = viewport.scroll_top() as f64;
let start_node = max(
0,
((scroll_top / self.props.row_height).floor() as isize)
- (node_padding as isize),
) as usize;
let total_visible = min(
((self.viewport_height / self.props.row_height).ceil()) as usize
+ 2 * node_padding,
self.props.items.len() - start_node,
);
let start_y = (start_node as f64) * self.props.row_height;
let end_node = start_node + total_visible;
self.content_window = Some(ContentWindow {
start_y,
visible_range: start_node..end_node,
});
true
}
}
}
fn change(&mut self, props: Self::Properties) -> yew::ShouldRender {
if self.props != props {
let should_rerender = self.props.class != props.class;
self.props = props;
self.link.send_message(Msg::CalculateWindowContent);
should_rerender
} else {
false
}
}
fn view(&self) -> yew::Html {
let total_content_height = (self.props.items.len() as f64) * self.props.row_height;
let content_style = format!("height: {}px", total_content_height);
let (window_style, windowed_items) = match &self.content_window {
Some(cw) => (
format!("{}transform: translateY({}px);", WINDOW_STYLES, cw.start_y),
(&self.props.items[cw.visible_range.clone()]).into(),
),
None => (WINDOW_STYLES.to_string(), vec![]),
};
let items = windowed_items.into_iter().map(|item| item.into());
let onscroll = self.link.callback(|_| Msg::CalculateWindowContent);
let onsize = self.link.batch_callback(|rect: ComponentSize| {
vec![
Msg::UpdateViewportHeight(rect.height),
Msg::CalculateWindowContent,
]
});
html! {
<div ref=self.viewport_ref.clone() onscroll=onscroll class=self.props.class.clone() style="position: relative; overflow: auto">
<div style=content_style>
<div style=window_style>
{for items}
</div>
</div>
<ComponentSizeObserver onsize=onsize />
</div>
}
}
fn rendered(&mut self, first_render: bool) {
if first_render {
self.link
.send_message_batch(vec![Msg::CalculateViewport, Msg::CalculateWindowContent]);
}
}
}