1#![deny(
4 missing_docs,
5 missing_debug_implementations,
6 bare_trait_objects,
7 anonymous_parameters,
8 elided_lifetimes_in_paths
9)]
10
11mod resize_observer;
12
13use core::fmt;
14use std::cell::RefCell;
15use std::fmt::Display;
16use std::rc::Rc;
17
18use gloo_timers::callback::Timeout;
19use resize_observer::{ObservedElement, ResizeObserver};
20use wasm_bindgen::prelude::wasm_bindgen;
21use wasm_bindgen::JsCast;
22use web_sys::Element;
23use yew::html::IntoPropValue;
24use yew::prelude::*;
25
26pub struct ItemGenerator {
30 gen: Rc<dyn Fn(usize) -> Html>,
31}
32
33impl fmt::Debug for ItemGenerator {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 f.debug_struct("ItemGenerator")
36 .field("gen", &"<function ptr>")
37 .finish_non_exhaustive()
38 }
39}
40
41impl ItemGenerator {
42 fn emit(&self, idx: usize) -> Html { (self.gen)(idx) }
43}
44
45impl PartialEq for ItemGenerator {
46 #[allow(clippy::vtable_address_comparisons)] fn eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.gen, &other.gen) }
48}
49
50impl VirtualList {
51 pub fn item_gen(gen: impl 'static + Fn(usize) -> Html) -> ItemGenerator { ItemGenerator { gen: Rc::new(gen) } }
54}
55
56#[derive(Debug, PartialEq)]
58#[non_exhaustive]
59pub enum ItemSize {
60 Pixels(usize),
62}
63
64impl ItemSize {
65 fn as_scroll_size(&self) -> i32 {
66 match self {
67 Self::Pixels(pxs) => (*pxs).try_into().unwrap(),
68 }
69 }
70}
71
72impl IntoPropValue<ItemSize> for usize {
73 fn into_prop_value(self) -> ItemSize { ItemSize::Pixels(self) }
74}
75
76impl Display for ItemSize {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 Self::Pixels(pxs) => write!(f, "{pxs}px"),
80 }
81 }
82}
83
84impl std::ops::Mul<&'_ ItemSize> for usize {
85 type Output = ItemSize;
86
87 fn mul(self, rhs: &ItemSize) -> Self::Output {
88 match rhs {
89 ItemSize::Pixels(pxs) => ItemSize::Pixels(self * pxs),
90 }
91 }
92}
93
94#[wasm_bindgen]
95extern "C" {
96 type PositionedElementDuck;
97 #[wasm_bindgen(method, getter, structural, js_name = __yew_resize_obs_pos)]
98 fn pos(this: &PositionedElementDuck) -> usize;
99 #[wasm_bindgen(method, setter, structural, js_name = __yew_resize_obs_pos)]
100 fn set_pos(this: &PositionedElementDuck, pos: usize);
101}
102
103#[derive(Properties)]
104struct ScrollWrapperProps {
105 observer: Rc<ResizeObserver>,
106 pos: usize,
107 children: Children,
108 classes: Classes,
109}
110
111impl PartialEq for ScrollWrapperProps {
112 fn eq(&self, other: &Self) -> bool { self.children == other.children }
113}
114
115#[function_component(ScrollItemWrapper)]
116fn scroll_item_wrapper(props: &ScrollWrapperProps) -> Html {
117 let wrapped_ref = use_node_ref();
118 let observed = use_mut_ref(|| Option::<ObservedElement>::None);
119 {
120 let wrapped_ref = wrapped_ref.clone();
121 let observer = props.observer.clone();
122 let pos = props.pos;
123 use_effect(move || {
124 let el = wrapped_ref.cast::<Element>().unwrap();
125 let positioned_el = el.unchecked_ref::<PositionedElementDuck>();
126 positioned_el.set_pos(pos);
127 let mut observed = observed.borrow_mut();
128 if matches!(&*observed, Some(observed) if observed.element() != &el) {
129 *observed = None;
130 }
131 if observed.is_none() {
132 *observed = Some(observer.observe(el));
133 }
134 || {}
135 })
136 }
137 html! {
138 <div ref={&wrapped_ref} class={props.classes.clone()}>
139 {props.children.clone()}
140 </div>
141 }
142}
143
144#[derive(Default, Debug)]
146struct EffectiveScrollState {
147 first_idx: usize,
148 past_last_idx: usize,
149 hidden_before: f64,
150 hidden_after: f64,
151}
152
153#[derive(Debug)]
155struct BackingScrollState {
156 element_sizes: RefCell<Vec<f64>>,
157 trigger_update: Callback<()>,
158}
159
160#[derive(Debug)]
161struct ScrollManager {
162 host_height: i32,
163 scroll_top: i32,
164 observer: Rc<ResizeObserver>,
165 shared: Rc<BackingScrollState>,
166 scroll_state: EffectiveScrollState,
167}
168
169impl ScrollManager {
170 fn new(trigger_update: Callback<()>) -> Self {
171 let shared = {
172 let trigger_update = trigger_update.clone();
173 Rc::new(BackingScrollState {
174 element_sizes: RefCell::default(),
175 trigger_update,
176 })
177 };
178 let observer = {
179 let shared = shared.clone();
180 Rc::new(ResizeObserver::new(move |change_entries| {
181 let mut element_sizes = shared.element_sizes.borrow_mut();
182 for change in change_entries {
183 let pos = change.target().unchecked_ref::<PositionedElementDuck>().pos();
184 element_sizes[pos] = change.content_rect().height();
185 }
186 drop(element_sizes);
187 trigger_update.emit(());
188 }))
189 };
190 ScrollManager {
191 host_height: 0,
192 scroll_top: 0,
193 observer,
194 shared,
195 scroll_state: Default::default(),
196 }
197 }
198
199 fn mounted(&mut self, host: Element) {
200 let height = host.client_height();
201 self.host_height = height;
202 self.shared.trigger_update.emit(());
203 }
204
205 fn update_scroll(&mut self, scroll_top: i32) {
206 if self.scroll_top != scroll_top {
207 self.scroll_top = scroll_top;
208 self.shared.trigger_update.emit(());
209 }
210 }
211
212 fn regenerate_scroll_state(&mut self, props: &VirtualListProps) {
213 self.scroll_state = self.generate_scroll_state(props);
214 }
215
216 fn generate_scroll_state(&self, props: &VirtualListProps) -> EffectiveScrollState {
217 let item_height = props.height_prior.as_scroll_size();
218 {
220 let mut element_sizes = self.shared.element_sizes.borrow_mut();
221 element_sizes.resize(props.item_count, item_height.into());
222 }
223
224 let element_sizes = self.shared.element_sizes.borrow();
225 const EXTRA_BUFFER: usize = 5;
227 let mut before_ring_buffered: [f64; EXTRA_BUFFER] = [0.0; EXTRA_BUFFER];
229 let mut before_ring_buff_idx = 0usize;
230 let mut first_idx = props.item_count;
231
232 let mut passed_height = 0f64;
233 for (i, i_size) in element_sizes.iter().enumerate() {
234 let height_before = passed_height;
235 passed_height += i_size;
236 if passed_height >= self.scroll_top.into() {
237 first_idx = i;
238 break;
239 }
240
241 before_ring_buffered[before_ring_buff_idx as usize] = height_before;
242 before_ring_buff_idx += 1;
243 before_ring_buff_idx %= before_ring_buffered.len();
244 }
245 let first_idx = first_idx.saturating_sub(EXTRA_BUFFER).min(props.item_count);
246 let hidden_before = before_ring_buffered[first_idx % EXTRA_BUFFER];
247
248 let mut past_last_idx = props.item_count;
249 let mut passed_height = hidden_before;
250 for (i, i_size) in element_sizes.iter().enumerate().skip(first_idx) {
251 passed_height += i_size;
252 if passed_height >= (self.scroll_top + self.host_height).into() {
253 past_last_idx = i.saturating_add(1 + EXTRA_BUFFER);
254 break;
255 }
256 }
257 let past_last_idx = past_last_idx.min(props.item_count);
258 let hidden_after: f64 = element_sizes[past_last_idx..].iter().sum();
259
260 EffectiveScrollState {
261 first_idx,
262 past_last_idx,
263 hidden_before,
264 hidden_after,
265 }
266 }
267
268 fn generate_contents(&self, props: &VirtualListProps) -> Html {
269 let EffectiveScrollState {
270 first_idx,
271 past_last_idx,
272 hidden_before,
273 hidden_after,
274 } = self.scroll_state;
275
276 let items = (first_idx..past_last_idx).map(|i| {
277 let item = props.items.emit(i);
278 html! {
279 <ScrollItemWrapper key={i} pos={i} observer={&self.observer} classes={props.item_classes.clone()}>
280 {item}
281 </ScrollItemWrapper>
282 }
283 });
284
285 html! {
286 <>
287 <div key="pre" style={format!("height: {hidden_before}px;")}>
288 </div>
289 <div key="wrap" style={"display: contents;"}>
290 {for items}
291 </div>
292 <div key="post" style={format!("height: {hidden_after}px;")}>
293 </div>
294 </>
295 }
296 }
297}
298
299#[derive(PartialEq, Properties, Debug)]
301pub struct VirtualListProps {
302 pub items: ItemGenerator,
305 pub item_count: usize,
309 pub height_prior: ItemSize,
316 pub classes: Classes,
324 #[prop_or_default]
328 pub item_classes: Classes,
329}
330
331fn debounced<E: 'static>(millis: u32, cb: Callback<E>) -> Callback<E> {
332 let debounced = Rc::new(RefCell::new(None));
333 Callback::from(move |scroll| {
334 let mut debounced_ref = debounced.borrow_mut();
335 if (*debounced_ref).is_some() {
336 return;
337 }
338 let cb = cb.clone();
339 let debounced = debounced.clone();
340 *debounced_ref = Some(Timeout::new(millis, move || {
341 cb.emit(scroll);
342 *debounced.borrow_mut() = None;
343 }))
344 })
345}
346
347#[derive(Debug)]
349pub struct VirtualListMsg(ScrollMsg);
350
351#[derive(Debug)]
352enum ScrollMsg {
353 Scroll(Event),
354 Update,
355}
356
357#[derive(Debug)]
382pub struct VirtualList {
383 manager: ScrollManager,
384 onscroll: Callback<Event>,
385 host_ref: NodeRef,
386}
387
388impl Component for VirtualList {
389 type Message = VirtualListMsg;
390 type Properties = VirtualListProps;
391
392 fn create(ctx: &Context<Self>) -> Self {
393 let trigger_update = ctx.link().callback(|()| VirtualListMsg(ScrollMsg::Update));
394 let manager = ScrollManager::new(trigger_update);
395 let onscroll = ctx.link().callback(|scroll| VirtualListMsg(ScrollMsg::Scroll(scroll)));
396 let onscroll = debounced(50, onscroll);
397 let host_ref = NodeRef::default();
398 Self {
399 manager,
400 onscroll,
401 host_ref,
402 }
403 }
404
405 fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
406 match msg {
407 VirtualListMsg(ScrollMsg::Scroll(scroll)) => {
408 let el = scroll.target_dyn_into::<web_sys::Element>().unwrap();
409 let scroll_top = el.scroll_top();
410 self.manager.update_scroll(scroll_top);
411 false
413 }
414 VirtualListMsg(ScrollMsg::Update) => {
415 self.manager.regenerate_scroll_state(ctx.props());
416 true
417 }
418 }
419 }
420
421 fn view(&self, ctx: &Context<Self>) -> Html {
422 let props = ctx.props();
423 let contents = self.manager.generate_contents(props);
424
425 html! {
426 <div ref={&self.host_ref} class={props.classes.clone()} style="overflow-y: scroll;" onscroll={&self.onscroll}>
427 {contents}
428 </div>
429 }
430 }
431
432 fn changed(&mut self, ctx: &Context<Self>, _props: &<Self as yew::Component>::Properties) -> bool {
433 ctx.link().send_message(VirtualListMsg(ScrollMsg::Update));
434 false
436 }
437
438 fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
439 if first_render {
440 let host = self.host_ref.cast::<Element>().unwrap();
441 self.manager.mounted(host);
442 }
443 }
444}