yew_virtualized/
lib.rs

1//! A virtualized list component for Yew.
2
3#![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
26/// A wrapper around a method generating individual items in the list.
27///
28/// To construct such a generator, use [`VirtualList::item_gen`]
29pub 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)] // We don't care about false negatives
47    fn eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.gen, &other.gen) }
48}
49
50impl VirtualList {
51    /// Construct an [`ItemGenerator`] that can be passed as a value of
52    /// [`VirtualListProps::items`].
53    pub fn item_gen(gen: impl 'static + Fn(usize) -> Html) -> ItemGenerator { ItemGenerator { gen: Rc::new(gen) } }
54}
55
56/// The height of each items, usually given in pixels.
57#[derive(Debug, PartialEq)]
58#[non_exhaustive]
59pub enum ItemSize {
60    /// A height in pixels
61    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/// Scroll state as reflected during rendering
145#[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/// Backing scroll state, as source of truth for item sizes, etc.
154#[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        // take care of some state change
219        {
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        // TODO: depend on item_height and scroll speed?
226        const EXTRA_BUFFER: usize = 5;
227        // TODO: rework to range-query datastructure
228        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/// Properties for a [`VirtualList`].
300#[derive(PartialEq, Properties, Debug)]
301pub struct VirtualListProps {
302    /// A callback to render individual items. Only invoked for items on screen.
303    /// Use [`VirtualList::item_gen`] to create an [`ItemGenerator`].
304    pub items: ItemGenerator,
305    /// The number of items in the list, in total. Items that are not visible on
306    /// screen take up scroll space and are lazily instantiated when the user
307    /// scrolls to them later.
308    pub item_count: usize,
309    /// An approximate height for items that haven't been rendered, yet, but
310    /// should still take up scroll space. After the first render of an
311    /// item, the height will be adjusted automatically and measured.
312    ///
313    /// Setting this to an inaccurate value will mis-represent the remaining
314    /// scroll distance, but cause no other ill effects.
315    pub height_prior: ItemSize,
316    /// Additional classes to apply to the scroll list itself.
317    ///
318    /// ### Gotcha
319    ///
320    /// The list itself is rendered without a max height or other layout
321    /// constraints to stay independent of a particular css solution. Use these
322    /// additional classes to apply additional css to the list.
323    pub classes: Classes,
324    /// Individual items are wrapped in a `<div>` to take their measurements in
325    /// a block context. The classes here are applied to each such wrapper.
326    /// Usually, you don't need to supply this property.
327    #[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/// Internal message type for a [`VirtualList`].
348#[derive(Debug)]
349pub struct VirtualListMsg(ScrollMsg);
350
351#[derive(Debug)]
352enum ScrollMsg {
353    Scroll(Event),
354    Update,
355}
356
357/// A virtualized list, rendering only items that are also shown on screen to
358/// the user.
359///
360/// ## Example
361///
362/// ```
363/// use yew::prelude::*;
364/// use yew_virtualized::VirtualList;
365///
366/// fn items(idx: usize) -> Html {
367///     html! { format!("Item #{idx}") }
368/// }
369///
370/// #[function_component(App)]
371/// fn app() -> Html {
372///     html! {
373///         <VirtualList
374///             item_count={100}
375///             height_prior={30}
376///             items={VirtualList::item_gen(items)}
377///             classes={"scrollbar"} />
378///     }
379/// }
380/// ```
381#[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                // triggered indirectly via Message::Update
412                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        // triggered indirectly via Message::Update
435        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}