Virtual Views

Introduction

A VirtualView is a single node. Its inner content comes from a separate callback. That callback only runs when needed.

It's azul's iframe-equivalent. Use it when the inner content would be too expensive to build every frame. Common cases:

  • An infinite list.
  • A 100,000-row table.
  • An editor with heavy decoration layers.
  • A panel that's logically independent from its surroundings.

The outer Dom containing the VirtualView is built inside layout() like any other node. The inside isn't built until the framework calls the callback.

use azul::prelude::*;

struct ListData { 
    items: Vec<String> 
}

extern "C" 
fn render_list(mut data: RefAny, info: VirtualViewCallbackInfo) -> VirtualViewReturn {
    let d = match data.downcast_ref::<ListData>() { 
        Some(d) => d, 
        None => return VirtualViewReturn::default() 
    };
    
    let dom: Dom = d.items.iter()
        .map(|s| Dom::create_li_with_text(s.clone()))
        .collect();
    
    let row_h = 24.0_f32;
    let total = LogicalSize::create(
        info.bounds.logical_size.width, 
        row_h * d.items.len() as f32
    );
    
    VirtualViewReturn::with_dom(
        dom, total, 
        LogicalPosition::zero(), total, 
        LogicalPosition::zero()
    )
}

let list_state = RefAny::new(ListData { 
    items: vec!["A".into(), "B".into()] 
});
let cb = VirtualViewCallback { 
    cb: render_list, 
    ctx: OptionRefAny::None 
};
let _ = Dom::create_virtual_view(list_state, cb);

Two coordinate systems: rendered vs virtual

The key idea is that the rendered size and the virtual size are separate numbers. That's what lets a scrollbar pretend to span 30,000 rows while only 30 rows actually live in the DOM.

VirtualViewReturn carries two pairs:

  • scroll_size and scroll_offset describe the actual rendered content. This is the box of DOM you're handing back. scroll_offset is where it sits in virtual coordinates.

  • virtual_scroll_size and virtual_scroll_offset describe what the view pretends to be. virtual_scroll_size is the size the scrollbar represents. virtual_scroll_offset is usually LogicalPosition::zero().

If every row is materialised, the rendered values match the virtual values. The abstraction collapses to a plain scrollable subtree.

For a virtualised slice, say rows 10..30 of a million-row table:

  • scroll_size is (width, 20 * row_height). That's the 20 actual rows.
  • scroll_offset is (0, 10 * row_height). The rendered rows start at y = 10 × row_height in virtual coordinates.
  • virtual_scroll_size is (width, 1_000_000 * row_height). The scrollbar represents the whole table.
  • virtual_scroll_offset is (0, 0).

The framework paints the scrollbar from virtual_scroll_size. It clips the rendered DOM to a viewport at scroll_offset of size scroll_size. If the user scrolls outside the rendered window, it re-invokes the callback.

Why the callback was invoked

Each invocation carries a VirtualViewCallbackReason. The variants are defined in core/src/callbacks.rs:

  • InitialRender. The first time the VirtualView appears. You always return content here.

  • DomRecreated. The parent tree was rebuilt from scratch. The cached subtree was thrown away. The callback has to rebuild.

  • BoundsExpanded. The window grew. The view's bounds now exceed its previous scroll_size. Time to enlarge the rendered content. This fires once per expansion, not on every resize tick.

  • EdgeScrolled(EdgeType). The user scrolled within approximately 200 px of one of the four edges of the rendered content. Time to lazy-load more rows. EdgeType is one of Top, Bottom, Left, Right. Fires once per edge approach. The flag clears once the scroll moves away.

  • ScrollBeyondContent. A programmatic scroll (e.g. set_scroll_position) jumped the offset past the rendered scroll_size.

The callback does not fire on small resizes that stay inside the rendered scroll_size. It does not fire when the window shrinks. It does not fire on parent re-renders that don't recreate the parent DOM. That's the optimisation.

Returning OptionDom::None

Sometimes the existing DOM is fine. The user scrolled, but stayed inside the already-rendered area. Or the parent re-rendered without invalidating the subtree.

Set dom: OptionDom::None in the return value. The previous DOM stays in place. Only the scroll bounds are updated.

fn render_table(data: &mut TableData, info: VirtualViewCallbackInfo) -> VirtualViewReturn {
    if data.already_rendered_area_covers(info.scroll_offset, info.bounds.logical_size) {
        return VirtualViewReturn {
            dom: OptionDom::None,                  // keep current DOM
            scroll_size:  data.current_scroll_size,
            scroll_offset: data.current_scroll_offset,
            virtual_scroll_size: data.virtual_size,
            virtual_scroll_offset: LogicalPosition::zero(),
        };
    }
    let new_dom = data.render_more_rows(info.scroll_offset, info.bounds.logical_size);
    /* ... */
    # VirtualViewReturn::default()
}

VirtualViewReturn::keep_current(...) is a shortcut for the same return shape.

This is the cheapest re-invocation. The most expensive ones are InitialRender and DomRecreated, since both rebuild from nothing.

A virtualised table, end to end

struct TableData {
    total_rows: usize,
    row_height: f32,
    visible_rows: Vec<Row>,        // currently rendered
    first_visible_row: usize,
}

extern "C" fn table_render(
    mut data: RefAny,
    info: VirtualViewCallbackInfo,
) -> VirtualViewReturn {
    let mut tdata = match data.downcast_mut::<TableData>() {
        Some(d) => d, None => return VirtualViewReturn::default(),
    };
    let container_h = info.bounds.logical_size.height;
    let scroll_y = info.scroll_offset.y;

    // Which rows should we render?
    let first = (scroll_y / tdata.row_height) as usize;
    let count = (container_h / tdata.row_height).ceil() as usize + 2; // +2 buffer

    tdata.visible_rows = tdata.fetch_rows(first, count);
    tdata.first_visible_row = first;

    let dom: Dom = tdata.visible_rows.iter().map(|r| {
        Dom::create_div()
            .with_child(Dom::create_text(r.text.clone()))
            .with_css(format!("height: {}px;", tdata.row_height))
    }).collect();

    VirtualViewReturn {
        dom: OptionDom::Some(dom),
        scroll_size: LogicalSize::create(
            info.bounds.logical_size.width,
            tdata.visible_rows.len() as f32 * tdata.row_height,
        ),
        scroll_offset: LogicalPosition::create(
            0.0, first as f32 * tdata.row_height,
        ),
        virtual_scroll_size: LogicalSize::create(
            info.bounds.logical_size.width,
            tdata.total_rows as f32 * tdata.row_height,
        ),
        virtual_scroll_offset: LogicalPosition::zero(),
    }
}

The user sees a scrollbar that represents all million rows. The DOM holds about 25 row nodes. The callback runs only when the user scrolls near an edge, the parent rebuilds, or the window grows. It doesn't run every frame.

Pairing with merge callbacks

The data parameter on a VirtualView is a RefAny. The framework keeps it alive across re-renders of the parent DOM. The inner Dom returned by the callback is rebuilt each time the callback fires.

If the inner content owns expensive resources (per-row decoders, GL textures), pair each row's root with with_dataset(...) and a merge callback. That way the resources transfer when the table is re-rendered.

The outer RefAny is also a good place to keep live state: what was rendered last time, the cached fetcher, in-flight requests. The callback can look at that state before deciding whether to return OptionDom::None.

Coming Up Next

  • Components — Reusable UI fragments - named functions of (args) -> Dom
  • Scrolling — Scroll containers, drag-and-drop, hit testing
  • Layout — Overview of the layout solver

Back to guide index