Skip to content

Slint — Repeater::ensure_updated_listview (Rust) ​

A compiled declarative UI where for over a model is a Repeater, and the virtualizing variant differs from the plain one by one method — the same instances, addressed through an offset into the model.

LanguageRust (with C++, JS, Python bindings)
LicenseGPL-3.0 / commercial / royalty-free
Repositoryslint-ui/slint (pinned at 24318ce)
Documentationslint.dev · docs/development/model-repeater-system.md
CategoryRetained (compiled item tree), GPU or software
Virtualization unitModel row ↔ instantiated item tree

See also Slint in the backend-seam catalog.

Overview ​

What it solves ​

In .slint, for item in model: Rectangle { … } compiles to a Repeater that owns a vector of instantiated item trees. Without virtualization the vector is as long as the model. ListView — a ScrollView whose content is a repeater — switches the repeater into a windowed mode.

Design philosophy ​

The design doc states the model plainly:

For ListView, only visible items are instantiated:

Model rows: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9]
                     ↑                   ↑
                   offset         offset + len

Instances:          [2] [3] [4] [5] [6]
                   (only visible rows instantiated)

The offset tracks which model row corresponds to instances[0].

— docs/development/model-repeater-system.md

An offset plus a dense instance vector — the minimal representation of a realized window, and structurally identical to Avalonia's RealizedStackElements or a ring of rows.

How it works ​

The repeater's inner state is three fields:

rust
struct RepeaterInner<C: RepeatedItemTree> {
    instances: Vec<(RepeatedInstanceState, Option<ItemTreeRc<C>>)>,
    offset: usize,              // For ListView virtualization
    cached_item_height: LogicalLength,
    // ...
}

— docs/development/model-repeater-system.md

cached_item_height beside offset is the whole extent story: the height of one item, remembered, is what lets the view convert between a scroll offset and a model index without measuring anything else.

The two update entry points differ exactly in whether a viewport is involved:

rust
impl<C: RepeatedItemTree> Repeater<C> {
    /// Ensure all instances are up-to-date
    pub fn ensure_updated(self: Pin<&Self>, init: impl Fn() -> ItemTreeRc<C>);

    /// For ListView with virtualization
    pub fn ensure_updated_listview(
        self: Pin<&Self>,
        init: impl Fn() -> ItemTreeRc<C>,
        viewport_width: Pin<&Property<LogicalLength>>,
        viewport_height: Pin<&Property<LogicalLength>>,
        viewport_y: Pin<&Property<LogicalLength>>,
        listview_width: LogicalLength,
        listview_height: Pin<&Property<LogicalLength>>,
    );
}

— docs/development/model-repeater-system.md

Note that ensure_updated_listview writes the viewport properties: it is handed viewport_height and viewport_y as mutable properties because computing the window also determines the virtual extent and may adjust the scroll position (if the model shrank, for instance). The virtualizer owns the scroll geometry; the scroll view merely displays it.

The reused-instance contract is the trait:

rust
pub trait RepeatedItemTree: ItemTree + HasStaticVTable<ItemTreeVTable> + 'static {
    type Data: 'static;

    /// Called when model data changes
    fn update(&self, index: usize, data: Self::Data);

    /// Called after first instantiation
    fn init(&self) {}

    /// For ListView layout
    fn listview_layout(self: Pin<&Self>, offset_y: &mut LogicalLength) -> LogicalLength;
}

— docs/development/model-repeater-system.md

init is setup, update(index, data) is bind — the GTK 4 split again, here as a compiled trait rather than signals, and generated by the compiler from the .slint source so the application never writes either.

Model changes arrive through a ModelChangeListener that marks is_dirty rather than rebuilding, and listview_geometry_tracker is a dedicated PropertyTracker so that a geometry change (a resize) re-runs the windowing without re-running the model diff.

Analysis ​

1. What the window bounds ​

Instantiation of item trees, layout, rendering, and data access through Model::row_data(i) — a real model protocol, implementable over lazily-loaded data.

2. How the range is computed ​

Division by cached_item_height for the common uniform case, with listview_layout accumulating offset_y per instance for the general one. Slint documents ListView as assuming items of equal height for correct scrollbar behaviour; heterogeneous items work but the extent degrades.

3. What survives between frames ​

The instances vector (reused across scroll — update rather than re-init), offset, cached_item_height, the model, and the dirty flags.

4. How the extent is known ​

Declared from cached_item_height × row_count, computed inside ensure_updated_listview and written into viewport_height — the same strategy as egui, reached from a retained architecture.

5. What breaks ​

  • Variable item heights make the extent approximate and scrollbar geometry wrong, which is why the documentation asks for uniform items.
  • State inside a repeated element follows the instance, not the row — the universal recycling hazard; Slint mitigates it by generating update from the declarative bindings, so declared properties are always refreshed and only hand-written state can go stale.
  • Two update paths (ensure_updated vs ensure_updated_listview) mean a for inside a ListView behaves differently from the same for elsewhere.

Strengths ​

  • The realized window is two fields: an offset and a dense vector.
  • Setup/bind is compiler-generated from declarative bindings, so the most common recycling bug is structurally prevented for anything expressed as a binding.
  • The virtualizer owning the viewport properties keeps extent and scroll position consistent by construction.
  • A dedicated geometry tracker separates "the data changed" from "the viewport changed".

Weaknesses ​

  • Uniform heights assumed for correct scrolling.
  • The mechanism is internal: an application cannot supply its own windowing policy, only a model.
  • The plain and virtualized repeater paths diverge, so behaviour depends on the enclosing widget.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
offset + dense instances vectorMinimal realized-window representation; index math is trivialOnly contiguous windows; no sparse realization for e.g. a focused far-away row
Compiler-generated init / updateThe bind half is derived from the declarative bindings, so it cannot be forgottenHand-written state in a repeated element is still the author's problem
cached_item_heightExact extent and O(1) seek without measuringHeterogeneous items degrade the scrollbar
Virtualizer writes the viewport propertiesExtent, scroll position and window stay consistentThe scroll view is not the owner of its own geometry
Separate listview_geometry_trackerA resize re-windows without re-diffing the modelTwo dirty paths to reason about

Sources ​