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}