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_sizeandscroll_offsetdescribe the actual rendered content. This is the box of DOM you're handing back.scroll_offsetis where it sits in virtual coordinates. -
virtual_scroll_sizeandvirtual_scroll_offsetdescribe what the view pretends to be.virtual_scroll_sizeis the size the scrollbar represents.virtual_scroll_offsetis usuallyLogicalPosition::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_sizeis(width, 20 * row_height). That's the 20 actual rows.scroll_offsetis(0, 10 * row_height). The rendered rows start at y = 10 × row_height in virtual coordinates.virtual_scroll_sizeis(width, 1_000_000 * row_height). The scrollbar represents the whole table.virtual_scroll_offsetis(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 theVirtualViewappears. 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 previousscroll_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.EdgeTypeis one ofTop,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 renderedscroll_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