list-10k
Ten thousand rows, and what drawing all of them costs.
<VirtualList> draws only the rows the viewport can see and reserves the height of the rest, so the frame time does not depend on the count. render is called with the index of each row that is on screen, the way react-virtualized calls rowRenderer; what the app owns is the data, and filtered is memoised because building ten thousand Strings every frame would cost more than drawing them.
This is also the honest example. <VirtualList> wraps egui::ScrollArea::show_rows, which is exactly what the plain egui version next to it calls — so the two tabs come out at the same length, and nothing about virtualization is a library feature. What the library adds here is that a row can have hooks: rows are drawn inside a scope of their own, like for plus key={i}. The repository's own measurements for drawing every row instead are in tests/scenarios.rs and docs/tasks/list-perf/.
Two notes about what you are looking at. The embedded version starts at 1,000 rows, to match the plain egui version beside it; the standalone binary opens with a hundred thousand, and the slider reaches that either way, since the rows are virtualised. And every row must be the same height — the list moves on by exactly row_h whatever a row draws, which is what keeps it in step with the range it asked for.
Run it yourself
cargo run -p list-10k
cargo run -p list-10k --bin list-10k-plain # the plain egui version
trunk serve --config examples/list-10k/Trunk.tomlThe source is examples/list-10k/src/lib.rs and plain.rs.
use std::collections::BTreeSet;
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use plain::PlainState;
/// A word per row, so the filter has something to match on.
pub const WORDS: [&str; 8] = [
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
];
/// How many rows the example opens with.
///
/// A hundred thousand: ten times the name, because the point is that the count
/// does not matter to the frame. It matters to the filter, which rebuilds the
/// row list on every keystroke (about 20 ms at this size). The gallery and the
/// snapshot pass a smaller number to match the plain column next to them.
pub const DEFAULT_COUNT: usize = 100_000;
/// The height of one row, and the gap under it. The plain version needs both as
/// numbers; here they are the row's natural height and a `gap` attribute.
pub const ROW_H: f32 = 18.0;
pub const ROW_GAP: f32 = 2.0;
/// The width of the index column, so the names line up.
pub const INDEX_W: f32 = 64.0;
/// Row `i`'s text. Deterministic, so both versions and every machine agree.
pub fn row_name(i: usize) -> String {
format!("row {i} {}", WORDS[i % WORDS.len()])
}
/// The rows that survive `removed` and `filter`, as `(index, text)`.
pub fn rows(count: usize, filter: &str, removed: &BTreeSet<usize>) -> Vec<(usize, String)> {
(0..count)
.filter(|i| !removed.contains(i))
.map(|i| (i, row_name(i)))
.filter(|(_, name)| filter.is_empty() || name.contains(filter))
.collect()
}
/// `initial_count` is the row count to open with — a hundred thousand by
/// default; the gallery and the tests pass something smaller.
#[component]
pub fn App(cx: &mut Cx, #[prop(default = DEFAULT_COUNT)] initial_count: usize) {
let mut count = use_state(cx, move || initial_count);
let mut filter = use_state(cx, String::new);
let mut removed = use_state(cx, BTreeSet::<usize>::new);
let frame_ms = cx.ui().input(|i| i.stable_dt) * 1000.0;
// Building ten thousand strings on every frame would be a bigger cost than
// drawing them. The deps are what the list depends on; removals only ever
// grow, so their count is enough to notice one.
let filtered = use_memo(cx, (*count, filter.as_str(), removed.len()), || {
rows(*count, filter.as_str(), &removed)
});
let shown = filtered.len();
rsx! {
<View direction="column" gap={8} p={12} grow={1.0}>
<Text size={22.0} strong>"list-10k"</Text>
<Slider bind={count.bind()} range={100..=100_000} label="rows"/>
<TextEdit w={220.0} bind={filter.bind()} hint="filter"/>
<View direction="row" gap={8} align="center">
<Text>{format!("showing {shown}")}</Text>
// Not a benchmark: one frame, as egui measured it, including
// whatever else the machine was doing.
<Text>{format!("last frame {frame_ms:.1} ms ({:.0} fps)", 1000.0 / frame_ms.max(0.001))}</Text>
</View>
// Render by index: only the rows in view are ever built, and the
// element decides which those are.
<VirtualList
grow={1.0}
rows={shown}
row_h={ROW_H + ROW_GAP}
render={|cx: &mut Cx<'_, '_>, row: usize| {
let (i, name) = &filtered[row];
rsx! { <Row index={*i} name={name.as_str()} on_remove={|| {
removed.insert(*i);
}}/> }
.show(cx);
}}
/>
</View>
}
}
/// One row, built by `<VirtualList>` for each row in view.
#[component]
pub fn Row(cx: &mut Cx, index: usize, name: &str, #[event] on_remove: ()) {
rsx! {
<View direction="row" gap={8} align="center" w="100%" h={ROW_H}>
<Text w={INDEX_W}>{format!("#{index}")}</Text>
<Text grow={1.0}>{name}</Text>
<Button label="remove" on_click={|| on_remove.emit(())}>"x"</Button>
</View>
}
}use std::collections::BTreeSet;
use crate::{DEFAULT_COUNT, INDEX_W, ROW_GAP, ROW_H, rows};
/// Everything the plain version keeps between frames, including the cache.
pub struct PlainState {
pub count: usize,
pub filter: String,
pub removed: BTreeSet<usize>,
/// The rows as last built, and what they were built from.
cache: Vec<(usize, String)>,
built_from: (usize, String, usize),
}
impl Default for PlainState {
fn default() -> Self {
Self::with_count(DEFAULT_COUNT)
}
}
impl PlainState {
pub fn with_count(count: usize) -> Self {
Self {
count,
filter: String::new(),
removed: BTreeSet::new(),
cache: Vec::new(),
built_from: (usize::MAX, String::new(), usize::MAX),
}
}
/// `use_memo`, by hand: compare the inputs, rebuild only on a change.
fn rows(&mut self) -> &[(usize, String)] {
let now = (self.count, self.filter.clone(), self.removed.len());
if self.built_from != now {
self.cache = rows(self.count, &self.filter, &self.removed);
self.built_from = now;
}
&self.cache
}
}
pub fn ui(ui: &mut egui::Ui, state: &mut PlainState) {
let frame_ms = ui.input(|i| i.stable_dt) * 1000.0;
let mut remove = None;
egui::Frame::new().inner_margin(12.0).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
ui.label(egui::RichText::new("list-10k").size(22.0).strong());
ui.add(egui::Slider::new(&mut state.count, 100..=100_000).text("rows"));
ui.add(
egui::TextEdit::singleline(&mut state.filter)
.desired_width(220.0)
.hint_text("filter"),
);
let shown = state.rows().len();
ui.horizontal(|ui| {
ui.label(format!("showing {shown}"));
ui.label(format!(
"last frame {frame_ms:.1} ms ({:.0} fps)",
1000.0 / frame_ms.max(0.001)
));
});
// The gap between rows is the scroll area's own item spacing, because
// `show_rows` adds it to the row height when it works out the range.
ui.spacing_mut().item_spacing.y = ROW_GAP;
let filtered = state.rows();
egui::ScrollArea::vertical().show_rows(ui, ROW_H, filtered.len(), |ui, range| {
for (i, name) in &filtered[range] {
ui.horizontal(|ui| {
// A column of its own width, like the other version's
// `<Text w={INDEX_W}>`. `add_sized` would centre the text
// in the box, and an allocation with no minimum would
// shrink to the text, so the names would not line up.
ui.allocate_ui_with_layout(
egui::vec2(INDEX_W, ROW_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.set_min_width(INDEX_W);
ui.label(format!("#{i}"))
},
);
ui.label(name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let button = ui.button("x");
// `<Button label="remove">`, by hand.
ui.ctx()
.accesskit_node_builder(button.id, |node| node.set_label("remove"));
if button.clicked() {
remove = Some(*i);
}
});
});
}
});
});
if let Some(i) = remove {
state.removed.insert(i);
}
}