Lists and grids
A list of a hundred thousand rows costs what the rows on screen cost: ListView builds
only those, as they scroll in. Give it how many rows there are, how tall each one is, and a
function that builds row i.
A long list
use frus::{component, Icon, Icons, ListTile, ListView, Widget};
fn invoices() -> Box<dyn Widget> {
Box::new(component(|cx| {
let selected = cx.use_state(|| None::<usize>);
let current = selected.get();
let select = selected.clone();
Box::new(ListView::new(10_000, 56.0, move |i| {
let select = select.clone();
ListTile::new()
.leading(Icon::new(Icons::RECEIPT))
.title(format!("Invoice #{}", i + 1))
.subtitle(format!("{:.2} €", (i as f32 * 7.31) % 500.0))
.selected(current == Some(i))
.on_tap(move || select.set(Some(i)))
}))
}))
}
A list takes the room it is given: the whole page, or what a Column leaves it when it is
wrapped in Expanded. .axis(Axis::Horizontal) makes it run across, .reverse() start at the
end (a chat), .padding(16.0) keep its rows off the edges.
ListTile is the usual row: leading, title, subtitle, trailing, on_tap. Any widget
can be a row, as long as it is as tall as the list says. CheckboxListTile,
SwitchListTile and RadioListTile are a tile and a control in one.
The data behind it
The rows usually come from a Vec the state holds. The builder is called with an index and
reads the item it needs:
use std::rc::Rc;
use frus::{component, ListTile, ListView, Widget};
#[derive(Clone)]
struct Contact {
name: String,
phone: String,
}
fn contacts(people: Vec<Contact>) -> Box<dyn Widget> {
let people = Rc::new(people);
Box::new(component(move |_cx| {
let rows = people.clone();
Box::new(ListView::new(people.len(), 64.0, move |i| {
ListTile::new().title(rows[i].name.clone()).subtitle(rows[i].phone.clone())
}))
}))
}
Rows of different heights
When the rows are not all the same height — posts of different lengths, messages with and
without a picture — ListView::builder(count, build) lays each one out at its own height. It
still builds only the rows in view: a row is measured the first time it scrolls in, and the
rows not seen yet are counted at the mean of those that were.
use std::rc::Rc;
use frus::{component, Column, ListView, Text, Widget};
struct Post {
author: String,
body: String,
}
fn feed(posts: Vec<Post>) -> Box<dyn Widget> {
let posts = Rc::new(posts);
Box::new(component(move |_cx| {
let shown = posts.clone();
Box::new(
ListView::builder(posts.len(), move |i| {
Column::new()
.spacing(4.0)
.child(Text::new(shown[i].author.clone()).size(13.0))
.child(Text::new(shown[i].body.clone()).wrap())
})
.estimated_extent(120.0)
.padding(16.0),
)
}))
}
.estimated_extent(..) is the height to count a row at before any is measured; a guess close
to the real rows keeps the scrollbar steady. Each row that can move — be removed, reordered,
inserted above — is given a key with keyed(id, row), so that its state stays with it rather
than with its position.
Pull to refresh, swipe to remove
RefreshIndicator wraps a list and calls you when it is pulled down; it spins for as long as
you say it is refreshing:
use std::time::Duration;
use frus::{component, host, ListTile, ListView, RefreshIndicator, Widget};
fn headlines() -> Box<dyn Widget> {
Box::new(component(|cx| {
let refreshing = cx.use_state(|| false);
let fetched = cx.use_state(|| 1u32);
let (start, done, count) = (refreshing.clone(), refreshing.clone(), fetched.clone());
let on_refresh = move || {
start.set(true);
let (done, count) = (done.clone(), count.clone());
// Stands for the request that fetches the new rows.
host::after(Duration::from_millis(1500), move || {
count.update(|n| *n += 1);
done.set(false);
});
};
let shown = fetched.get();
let list = ListView::new(20, 48.0, move |i| {
ListTile::new().title(format!("Headline {} (fetch #{shown})", i + 1))
});
Box::new(
RefreshIndicator::new(list)
.on_refresh(on_refresh)
.refreshing(refreshing.get()),
)
}))
}
Dismissible lets a row be swiped away, with what to show underneath while it moves and what
to do once it is gone:
use frus::{component, keyed, Align, Colors, Column, Container, Dismissible, ListTile, Text, Widget};
fn inbox() -> Box<dyn Widget> {
Box::new(component(|cx| {
let mail = cx.use_state(|| vec!["Lunch on Friday?", "Your invoice", "Weekly report"]);
let rows = mail.get().into_iter().fold(
Column::new().cross_axis_alignment(Align::Stretch),
|column, subject| {
let remove = mail.clone();
let row = Dismissible::new(ListTile::new().title(subject))
.height(56.0)
.background(Container::new().color(Colors::RED).child(Text::new("Delete")))
.on_dismiss_to_start(move || remove.update(|m| m.retain(|s| *s != subject)));
column.child(keyed(subject, row))
},
);
Box::new(rows)
}))
}
Grids
GridView::new(columns) lays out the cells it is given; GridView::extent(max, count, build)
fits as many columns as it can of tiles no wider than max; GridView::builder(columns, count, build) is the virtualised one, for thousands of tiles.
use frus::{Center, Color, Container, GridView, Text, Widget};
fn tile(i: usize) -> Container {
Container::new()
.radius(6.0)
.color(Color::hex("#6366F1"))
.child(Center::new(Text::new(format!("{}", i + 1))))
}
fn photos() -> Box<dyn Widget> {
Box::new(GridView::extent(120.0, 60, tile).gap(6.0).aspect(1.0))
}
fn catalogue() -> Box<dyn Widget> {
Box::new(GridView::builder(3, 5_000, tile).gap(4.0).tile_height(120.0))
}
Moving a list from code
A list wrapped in keyed(key, list) can be scrolled from anywhere with host::scroll_to: to
its start, its end, or an offset. It animates unless told .instant().
use frus::{host, keyed, Button, Column, Expanded, ListTile, ListView, ScrollTo, Widget};
const MESSAGES: &str = "messages";
fn conversation() -> Box<dyn Widget> {
Box::new(
Column::new()
.child(Expanded::new(keyed(
MESSAGES,
ListView::new(500, 48.0, |i| ListTile::new().title(format!("Message {i}"))),
)))
.child(
Button::new("Latest").on_press(|| host::scroll_to(MESSAGES, ScrollTo::end())),
),
)
}
ScrollTo::index(n) brings row n to the top of the window — the message a notification
pointed at, the selected row of a long list — and .aligned(0.5) to its middle. It works in a
list of rows of any height too: the rows on the way are measured as the list travels, and the
row lands where it was asked for.
use frus::{host, keyed, Button, Column, Expanded, ListTile, ListView, ScrollTo, Widget};
fn threads() -> Box<dyn Widget> {
Box::new(
Column::new()
.child(Button::new("Go to the unread one").on_press(|| {
host::scroll_to("threads", ScrollTo::index(240).aligned(0.5))
}))
.child(Expanded::new(keyed(
"threads",
ListView::new(1000, 56.0, |i| ListTile::new().title(format!("Thread {i}"))),
))),
)
}
.on_scroll(|position| ..) tells you where the list is — to load the next page when the reader
nears the end, for instance: position.offset.1 > position.max.1 - 400.0.