Skip to content

Widget VirtualList

MeowLynxSea edited this page Jun 18, 2026 · 5 revisions

VirtualList widget

A virtualised list. Only the rows visible in the scroll viewport (plus a small overdraw) are mounted, no matter how many items the controller reports.

  • virtual_list — variable row heights.
  • uniform_virtual_list — fixed row height (cheaper; one item builder for every row).

When to use

  • Long scrolling lists (settings pages, search results, file trees, infinite feeds).
  • Any UI where you want smooth scrolling and you can structure content as rows.
  • uniform_virtual_list when every row is the same height — it measures only the first row, materially faster than gpui::list for large lists.

Not a good fit:

  • A single giant scroll view that is one huge item — virtualization cannot help if one item is huge.
  • Variable-height lists where every row also needs a custom on_visible_range_change — the uniform variant does not expose a scroll handler.

Controllers

use yororen_ui::headless::virtual_list::{
    UniformVirtualListController, VirtualListController,
};

// Variable height — owns the item count.
let controller = VirtualListController::with_default(0);

// Or with explicit alignment + overdraw.
let controller = VirtualListController::new(0, gpui::ListAlignment::Top, px(16.));

// Fixed height — does NOT own the count; the caller passes it each frame.
let controller = UniformVirtualListController::new();
Method Purpose
reset(n) Inform the list the item count changed to n. Scroll snaps to top.
splice(old, new) Items in old were replaced by new items.
append(n) Append n items at the tail; preserves current scroll.
scroll_to_reveal_item(ix) / scroll_to_top() / scroll_to_bottom() Scroll helpers. (UniformVirtualListController exposes scroll_to_item instead of scroll_to_reveal_item.)

Factories

fn virtual_list(id, controller: &VirtualListController, cx: &mut App) -> VirtualListProps
fn uniform_virtual_list(id, item_count: usize, controller: &UniformVirtualListController, cx: &mut App) -> UniformVirtualListProps

Builder methods

Method Purpose
item_count(n) Override the count (otherwise read from the controller on each frame).
alignment(a) ListAlignment::Top / Center / Bottom. virtual_list only.
overdraw(px) Extra pixels above/below the viewport kept mounted. Default 16 px. virtual_list only.
sizing(b) ListSizingBehavior (auto / fixed).
row(closure) Required. FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static.
on_visible_range_change(closure) FnMut(Range<usize>, usize, &mut Window, &mut App) + 'static. virtual_list only.
render(cx) Consume the props; returns Stateful<Div>.

Updating the count

When the row count changes, notify both the controller and the props. The simplest mental model:

  • Bulk replacecontroller.reset(new_count). Scroll snaps to top.
  • In-place editcontroller.splice(old, new).
  • Infinite load (append)controller.append(n). Preserves scroll position.

gpui::ListState is Rc<RefCell<…>>. The on_visible_range_change callback fires while the RefCell is already borrowed by paint — calling controller.reset(...) from inside the callback would panic with "RefCell already borrowed". Write to a plain field inside the callback and sync the controller at the top of the next render.

Worked example

struct FeedView {
    list_controller: VirtualListController,
    item_count: usize,
    selected: Option<usize>,
}

impl Render for FeedView {
    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let current = self.list_controller.state().item_count();
        if current < self.item_count {
            self.list_controller.append(self.item_count - current);
        }

        let row_entity = cx.entity().clone();
        let range_entity = cx.entity().clone();

        virtual_list("feed", &self.list_controller, cx)
            .row(move |ix, _w, cx| {
                let selected = row_entity.read(cx).selected == Some(ix);
                list_item(format!("feed-row-{ix}"), format!("Item {ix}"), cx)
                    .selected(selected)
                    .on_click({
                        let row_entity = row_entity.clone();
                        move |_, _, cx| row_entity.update(cx, |s, _| s.selected = Some(ix));
                    })
                    .render(cx)
                    .into_any_element()
            })
            .on_visible_range_change(move |range, total, _w, cx| {
                range_entity.update(cx, |s, _| {
                    if range.end + 50 >= total && s.item_count < 10_000 {
                        s.item_count += 100;
                    }
                });
            })
            .render(cx)
            .w(px(320.)).h(px(360.))
    }
}

For the fixed-height variant the count is passed directly to uniform_virtual_list(id, count, &controller, cx) each frame and on_visible_range_change is not available.

See also

Clone this wiki locally