Expand description
Virtualized 2D tile grid bound to a ListModel<T> / ListDataSource.
GridView is the photo-gallery / icon-view / file-manager-grid /
collection-view widget — the 2D sibling of ListView
and TableView. It realizes only the
tiles currently visible (plus a buffer), reflows on resize, supports
single / multi selection with 2D keyboard navigation, and is fully
accessible (Role::Grid → Role::GridCell).
The layout is pluggable via GridLayoutStrategy: the stock
UniformGrid gives fixed tile size / fixed column count /
adaptive min-width grids, VariableRowGrid sizes each row to its
tallest tile, and VirtualizedMasonry flows items into the
currently-shortest column. Marquee selection, drag-reorder, sections
and sticky headers are layered over whichever one is in force.
GridView::new(model, |tc| {
Box::new(Card::new().child(TextWidget::new(lit!(&tc.item.name))))
})
.sizing(GridSizing::Adaptive { min_width: 120.0, max_width: None, height: 140.0 })
.spacing(8.0)
.selection(selection_model)§Column bounds and alignment
GridView::min_columns and GridView::max_columns bound the column
count that GridSizing::Fixed and GridSizing::Adaptive derive from the
width (GridSizing::FixedColumnCount ignores both). A floor the width
cannot afford narrows the tiles to share it, never overflowing
horizontally; the tile height stays the sizing’s. GridView::column_alignment
places a block of columns narrower than the content at the leading edge (the
default), the centre or the trailing edge, swapping sides in a right-to-left
layout. Section headers and the detail band keep spanning the content width;
tiles, the rubber band, the insertion bar and the focus ring follow the
offset. An album grid of fixed 182 dp cards, two to ten columns, centred:
GridView::new(albums, |tc| album_card(tc))
.sizing(GridSizing::Fixed { width: 182.0, height: 230.0 })
.min_columns(2)
.max_columns(10)
.column_alignment(HAlignment::Center)§Selection by position or by key
GridView::selection takes a SelectionModel, which holds positions.
A grid that is sorted or filtered under its selection, or fed by a lazy
source, is built with GridView::from_source_keyed instead: a
KeyedSelectionModel holds the source’s keys, so the selection stays on
the same items when they move. Both reach the grid through the facade the
other data views use (data_views::RowSelection), which turns each click,
key and rubber band into an index or a key.
§The detail band
GridView::detail_row gives a tile a full-width band that opens directly
under the row holding it, the “album expansion” of a music library. The
band is not a tile and not a row of the grid: a layout strategy wrapped
around the grid’s own (detail::DetailRowStrategy) moves everything below
the open row down by the band, so virtualization, the scroll range, the
focus ring and the rubber band account for it without knowing about it, and
tiles keep their row and column numbers. The band widget is a child of this
root rather than of the body pane, so it survives the pane’s rebuilds on
scroll and resize, and a lazy source loading a window. A waterfall has no
rows and ignores it. See the module docs of grid_view/detail.rs.
Focus inside the band is revealed like focus anywhere: this root answers
the framework’s WidgetEvent::ScrollIntoView, which a scroll container is
sent when a descendant it clips takes focus.
§Scrolling from outside
GridView::handle returns a GridViewHandle, a cheap clone usable
before the grid is mounted and through all its rebuilds:
scroll_to_index,
ensure_index_visible and
reveal_band (a tile and its open band
together). A handle computes no offset when it is called: it records the
request, and the next layout applies it once the band is measured and the
scroll anchoring has run. Opening a band and scrolling to a tile under it
in one turn therefore lands the tile where it was asked to be, which an
offset written from outside does not: the layout keeps the content under
the top edge still across the band opening, and takes that offset for
one of the old layout. The builder’s own scroll_to_index and
ensure_index_visible act at call time, so before the first layout they
do nothing.
let handle = grid.handle();
// later, from a command:
expanded.set(Some(album));
handle.reveal_band(album);§The keyboard cursor
The grid has one current tile, its keyboard cursor: the tile the arrow
keys step from, the one the focus ring marks while the grid holds keyboard
focus, and the one it names to assistive technology as its active
descendant. The same GridViewHandle reads and moves it:
cursor,
set_cursor (clamped to the last tile;
keyboard focus, the selection and the scroll offset are left alone) and
cursor_signal, which an application
observes to key a status line or its context actions on the current tile.
Like a reveal, a cursor set before the grid is mounted is there at its
first build.
The cursor and the selection are one tile in a single selection: the keys
move them together, and a selection written from outside moves the cursor
onto it. In a multiple selection they are separate: Ctrl+Arrow moves
the cursor and leaves the selection, so after →, Ctrl+→, Ctrl+↓ the
selection is still tile 0 while the cursor, the ring and the active
descendant are on tile 4 (in three columns), and a selection written from
outside leaves the cursor where it was. Read the cursor, not the first
selected tile, for “the tile the user is on”.
An open detail band is on the keyboard path
already: ↓ on the open tile moves focus into its band, onto its first
control, while the cursor stays on the tile, and ↑ from the band’s top
comes back to it. A band with no control to focus is stepped over. An
application has no walk of its own to write.
§A long band
A band that holds hundreds of rows, an album’s tracks say, need not build
them all. GridView::detail_visible_span_signal publishes the part of
the open band on screen, in band-local coordinates, and only when it
changes, so the band can realize the rows in the span and stand spacers
in for the rest, with no copy of the grid’s placement arithmetic:
const ROW: f32 = 24.0;
let grid = GridView::new(albums, album_card);
let span = grid.detail_visible_span_signal().clone();
grid.detail_row(move |tc| {
let n = tc.item.tracks.len();
let first = span.map(|s| s.as_ref().map_or(0, |r| (r.start / ROW) as usize));
let last = span.map(move |s| {
s.as_ref().map_or(0, |r| ((r.end / ROW).ceil() as usize).min(n))
});
// `window` holds tracks[first..last], refilled when the span moves.
let window = track_window(&tc.item.tracks, &first, &last);
Some(Box::new(
VStack::new()
.child(FixedSize::new().height(first.map(|f| *f as f32 * ROW)))
.child(Repeater::new(window, |track| track_row(track)))
.child(FixedSize::new().height(last.map(move |l| (n - *l) as f32 * ROW))),
))
})§Pan to scroll
The view installs common::scrollable::ScrollableBehavior,
which gives it the shared wheel arithmetic, a finger’s pan and the
PanClaim that puts it on a pan’s claimant chain. A pan scrolls it, the
release coasts, and a pan it cannot absorb hands the whole event to the
container outside — never a residual. Vertical only, despite the grid: this
view owns no horizontal offset, so a horizontal pan is declined and chains
outward. A pan that starts on a tile scrolls rather than activating it.
§The rubber band, and why it is not on this node
In teksilo_data::SelectionMode::Multi a drag on the empty background
sweeps a selection rectangle. That drag deliberately does not live on
this view’s own node, which is the one carrying the PanClaim.
The reason it was structural has since been fixed in the framework: a node
that both declares a claim and carries on_drag is now given a say through
PointerSequence::defer_own_drag, which resolves its
[teksilo_tokens::DragActivation] against the claim it is competing with.
Before that arm existed the claim holder’s own drag latched at drag_slop
and decided the sequence before the claim could win at pan_slop, so a
Multi-selection grid did not scroll under a finger from anywhere at all,
and a finger on the background swept a band immediately rather than after a
hold.
What the surface still buys is scope, and that is why it stays. This
root’s children are the body pane, the scrollbar, the pinned section header,
the empty view, the loading view and the focus overlay — all siblings, most
of them filling the same rectangle. A marquee hung here would be a drag
ancestor of every one of them; hung on a DragSurface that encloses the body
pane alone, it sweeps the background and nothing else. The wrapper is also
not this view’s to remove in any case: the four row views wrap each row
in one, and a row is not a claimant, so defer_own_drag — which refuses
anything but a live MemberRole::Pan member — cannot reach it. See
crate::data_views::DragSurface.
So the body pane carries a no-op tap that gives it an arena of its own (the
press is captured inside the surface, not by it) and the marquee’s drag
hangs on a DragSurface that strictly encloses the pane — the shape the tree
arms DragActivation for through its ancestor walk, which is what makes the
marquee wait for a long press under a finger and latch at 5 dp under a mouse.
Every tile carries that same no-op tap as well, for a reason with nothing to do with dragging. With the pane holding one, a tile without an arena of its own leaves the pane as the press captor — and a release is dispatched to the captor and then bubbled target→root, which never reaches a tile beneath it. A plain selectable grid lost both its finger tap and, under a mouse, the release that collapses a multi-selection that way.
Re-exports§
pub use sections::SectionProvider as GridSectionProvider;pub use sections::GroupingSections;pub use sections::SectionProvider;pub use sections::grouping_sections;pub use layout::GridSizing;pub use layout::ScrollAnchor;
Modules§
Structs§
- Grid
View - A virtualized 2D tile grid backed by a
ListModel<T>. - Grid
View Handle - A handle on a
GridView’s scroll position, for a caller outside the widget: a toolbar, a “show current” command, a search result, an observer of the application’s own state. - Tile
Context - Context passed to the tile delegate for each realized tile.
Enums§
- Grid
TabTraversal - How Tab moves out of (or within) the grid.