Skip to main content

smudge_widgets/
virtual_list.rs

1//! The virtual list: a scroll view that keeps only the visible rows mounted.
2//!
3//! The list never gives the layout engine more than a window of rows, so a
4//! list of any length costs the tree and the layout the same as its visible
5//! part. The rows hang, absolutely positioned, from a spacer panel as tall as
6//! the whole list, which is what keeps the scrollbar and the wheel geometry
7//! honest while the mounted window moves.
8//!
9//! This list is the fixed-height one: every row is `row_height` tall and the
10//! list measures nothing. When the rows differ in height or the item list
11//! edits in the middle, use [`virtual_list_keyed`](crate::virtual_list_keyed),
12//! which measures rows and anchors the view across edits.
13
14use std::rc::Rc;
15
16use smudge_core::element::{Element, IntoElement, for_each};
17use smudge_core::reactive::{RwSignal, prelude::*};
18use smudge_core::style::{Edges, Length, px};
19use smudge_core::widget::panel;
20
21use crate::scroll::{ScrollExt, ScrollView, scroll_with_visible_height};
22
23/// The rows mounted above and below the viewport, so a small scroll shows
24/// content in the frame before the mount follows it.
25const OVERSCAN: usize = 4;
26
27/// Creates a list of `count` rows of one height inside a scroll view, with
28/// only the rows near the viewport mounted.
29///
30/// The returned element is the scroll view; give it a height, either directly
31/// or through a parent that sizes it. It scrolls with the wheel like any
32/// scroll view, and it shows a thumb for the whole list, because its content
33/// is a spacer panel as tall as `count` rows, whatever the mounted window is.
34///
35/// The rows are built by `row`, which receives the row's index. A row is
36/// built once, the first time its index enters the window, and it stays
37/// mounted, with its reactive state, until the window moves past it. Content
38/// that changes while a row stays mounted must come from the signals that row
39/// reads: a new value for an index that never left the window reaches no
40/// mounted row. The row should keep to `row_height`; a taller one overlaps
41/// the row below it, and a shorter one leaves the spacer's background showing.
42///
43/// `count` runs inside a binding, so only the signals it reads remount the
44/// window. A shrinking count clamps the offset, and the window follows on the
45/// frame after the new height has been measured.
46///
47/// The list owns its scroll offset; do not call
48/// [`ScrollExt::offset`](crate::ScrollExt::offset) on the returned view,
49/// because the window would follow the signal the list started with while the
50/// view scrolls by the one the call installs.
51///
52/// `row_height` must be positive and finite. Anything else mounts no rows,
53/// and a list built that way shows an empty view.
54///
55/// ```
56/// use smudge_core::reactive::{RwSignal, prelude::*};
57/// use smudge_core::style::px;
58/// use smudge_text::label;
59/// use smudge_widgets::virtual_list;
60///
61/// let count = RwSignal::new(100_000);
62/// let list = virtual_list(
63///     move || count.get(),
64///     24.0,
65///     |index| label(format!("Row {index}")),
66/// )
67/// .height(px(480.0));
68/// ```
69pub fn virtual_list<E: IntoElement>(
70    count: impl Fn() -> usize + 'static,
71    row_height: f32,
72    row: impl Fn(usize) -> E + 'static,
73) -> Element<ScrollView> {
74    let row_height = if row_height.is_finite() && row_height > 0.0 {
75        row_height
76    } else {
77        0.0
78    };
79    let count = Rc::new(count);
80    let row = Rc::new(row);
81    let visible: RwSignal<Option<f32>> = RwSignal::new(None);
82    let offset = RwSignal::new(0.0);
83
84    let range_count = Rc::clone(&count);
85    let range_offset = offset.clone();
86    let range_visible = visible.clone();
87    let rows = for_each(
88        move || {
89            let count = range_count();
90            let Some(viewport) = range_visible.get() else {
91                return Vec::new();
92            };
93            if row_height == 0.0 {
94                return Vec::new();
95            }
96            let offset = range_offset.get();
97            let first = (offset / row_height).floor();
98            let last = ((offset + viewport) / row_height).ceil();
99            let start = (first as usize).saturating_sub(OVERSCAN);
100            let end = (last as usize).saturating_add(OVERSCAN).min(count);
101            (start..end).collect()
102        },
103        |&index| index,
104        move |index| {
105            panel()
106                .absolute()
107                .inset(Edges {
108                    top: px(index as f32 * row_height),
109                    right: px(0.0),
110                    bottom: Length::Auto,
111                    left: px(0.0),
112                })
113                .child(row(index))
114        },
115    );
116
117    let height_count = Rc::clone(&count);
118    // A column stretches the keyed slot to the list's width, which the
119    // absolutely positioned rows then span.
120    let content = panel()
121        .column()
122        .height(move || px(height_count() as f32 * row_height))
123        .child(rows);
124
125    scroll_with_visible_height(content, visible, None).offset(offset)
126}