教程
这篇教程带你把 basic 模板改造成一个购物清单:可以添加条目、勾选、删除,列表很长时能滚动,还能切换到深色主题。每一步只改动 src/app.rs 的一小部分,告诉你屏幕上会出现什么变化,并链接到深入讲解相关概念的页面。你可以先一路做下去,之后再回头细看。
准备工作
按照快速开始的说明,用 basic 模板创建项目并启动开发循环:
cargo tessera new groceries --template basic
cd groceries
cargo tessera dev写代码时让 dev 一直运行。每次保存文件,它都会重新构建并重启应用。因为进程会重启,每次保存后应用都会重新开始,你在应用里输入的内容也会清空,这是正常现象。
打开 src/app.rs,模板里的内容是这样的:
use tessera_components::{
modifier::ModifierExt as _,
surface::surface,
text::text,
theme::{MaterialTheme, material_theme},
};
use tessera_ui::{Modifier, tessera};
#[tessera]
pub fn app() {
material_theme()
.theme(|| MaterialTheme::default())
.child(|| {
surface()
.modifier(Modifier::new().fill_max_size())
.child(|| {
text("Hello Tessera!");
});
});
}从外往里读:material_theme() 为内部的所有内容提供 Material 主题,后面加入的 Material 控件都依赖它;surface() 铺满窗口,并绘制主题的背景色;text 显示问候语。容器通过闭包接收子内容,所以每一层都写成 .child(|| { ... })。
第 1 步:编写自己的组件
组件就是标注了 #[tessera] 的 Rust 函数。为单个购物条目写一个组件,并用它替换问候语:
use tessera_components::{
modifier::ModifierExt as _,
surface::surface,
text::text,
theme::{MaterialTheme, material_theme},
};
use tessera_ui::{Modifier, tessera};
#[tessera]
pub fn app() {
material_theme()
.theme(|| MaterialTheme::default())
.child(|| {
surface()
.modifier(Modifier::new().fill_max_size())
.child(|| {
grocery_item("Milk");
});
});
}
#[tessera]
fn grocery_item(#[prop(into)] name: String) {
text(name);
}原来显示问候语的地方现在显示“Milk”。
name 的类型是普通的 String,因此它是 grocery_item 的必填参数。#[prop(into)] 允许调用方传入任何能转换成 String 的值,比如这里的 &str 字面量。调用 grocery_item("Milk") 声明的是这个组件出现在树中的这个位置,Tessera 在构建界面时执行它。Tessera 的思维方式讲解了这套模型。
第 2 步:用列排列条目
清单需要多个条目。把整个界面移到单独的 grocery_screen 组件里,用 column 把标题和几个条目竖着排列:
use tessera_components::{
column::column,
modifier::ModifierExt as _,
spacer::spacer,
surface::surface,
text::text,
theme::{MaterialTheme, material_theme},
};
use tessera_ui::{Dp, Modifier, tessera};
#[tessera]
pub fn app() {
material_theme()
.theme(|| MaterialTheme::default())
.child(|| {
surface()
.modifier(Modifier::new().fill_max_size())
.child(|| {
grocery_screen();
});
});
}
#[tessera]
fn grocery_screen() {
column()
.modifier(Modifier::new().fill_max_size().padding_all(Dp(16.0)))
.children(|| {
text("Groceries");
spacer().modifier(Modifier::new().height(Dp(8.0)));
grocery_item("Milk");
grocery_item("Eggs");
grocery_item("Bread");
});
}
#[tessera]
fn grocery_item(#[prop(into)] name: String) {
text(name).modifier(Modifier::new().padding_symmetric(Dp(0.0), Dp(12.0)));
}现在你会看到标题“Groceries”,下面是三个条目,内容与窗口边缘相隔 16 dp。
column 按声明顺序从上到下放置子组件。尺寸和间距由 Modifier 链决定:列铺满整个 surface 并为内容留出内边距,固定高度的 spacer 留出一段空隙,每个条目都有上下内边距,行与行之间留有空间。尺寸用 Dp 表示,Tessera 会按屏幕密度换算成像素。把 grocery_screen 拆出来以后,app 只负责应用级的设置,第 6 步就会用到这一点。详见布局基础和修饰符。
第 3 步:用文本框添加条目
真正的清单一开始是空的,随着输入逐渐变长。为此需要三样东西:保存条目的状态、一个文本框,以及把文本框内容放进清单的操作。把 src/app.rs 替换为:
use tessera_components::{
alignment::CrossAxisAlignment,
boxed::boxed,
button::button,
column::column,
modifier::ModifierExt as _,
row::row,
spacer::spacer,
surface::surface,
text::text,
text_field::text_field,
text_input::TextInputController,
theme::{MaterialTheme, material_theme},
};
use tessera_ui::{Callback, Dp, Modifier, key, remember, tessera};
#[tessera]
pub fn app() {
material_theme()
.theme(|| MaterialTheme::default())
.child(|| {
surface()
.modifier(Modifier::new().fill_max_size())
.child(|| {
grocery_screen();
});
});
}
#[tessera]
fn grocery_item(#[prop(into)] name: String) {
text(name).modifier(Modifier::new().padding_symmetric(Dp(0.0), Dp(12.0)));
}
#[derive(Clone, PartialEq)]
struct Grocery {
id: u64,
name: String,
}
#[tessera]
fn grocery_screen() {
let items = remember(Vec::<Grocery>::new);
let next_id = remember(|| 0_u64);
let editor = remember(|| TextInputController::new(Dp(16.0), Some(Dp(24.0))));
let add = Callback::new(move || {
let name = editor.with(|editor| editor.text()).trim().to_string();
if name.is_empty() {
return;
}
let id = next_id.get();
next_id.set(id + 1);
items.with_mut(|items| items.push(Grocery { id, name }));
editor.with_mut(|editor| editor.set_text(""));
});
column()
.modifier(Modifier::new().fill_max_size().padding_all(Dp(16.0)))
.children(move || {
text("Groceries");
spacer().modifier(Modifier::new().height(Dp(8.0)));
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
boxed().modifier(Modifier::new().weight(1.0)).children(move || {
text_field()
.modifier(Modifier::new().fill_max_width())
.controller(editor)
.label("Add an item")
.on_submit_shared(add);
});
spacer().modifier(Modifier::new().width(Dp(8.0)));
button().on_click_shared(add).child(|| text("Add"));
});
for item in items.get() {
key(item.id, || {
grocery_item(item.name);
});
}
});
}清单现在从空开始,标题下方出现了一个文本框,旁边是 Add 按钮。输入名称后点击 Add 或按回车,条目就会出现在下方,文本框随之清空。
组件函数在每次重建时都会重新执行,普通的局部 Vec 每次都会从空开始。remember 让值在重建之间保留下来,并返回一个 State 句柄。它的初始化函数只在状态首次创建时运行一次。列的内容在构建时读取了 items,所以操作写入 items 之后,这部分内容会带着新列表重建。状态一页有详细说明。
文本框把文字保存在 TextInputController 中。自己用 remember 保存一个控制器,而不是让文本框自行创建,操作才能读取并清空其中的文字。Callback::new 把操作包装成一个在重建之间保持不变的句柄,按钮和文本框的回车键因此可以通过各自的 _shared setter 共用它。在这一行里,weight(1.0) 把按钮和间隔用剩的宽度全部交给外面那层 box,文本框再填满这层 box。
最后,key(item.id, ...) 让每一行与条目的 id 绑定,无论条目在列表中的什么位置。下一步就会用到它:条目可以删除之后,它能保证每一行始终对应正确的条目。生命周期与副作用解释了身份与 key,文本与文本输入介绍了文本框及其控制器。
第 4 步:勾选和删除条目
接下来给每个条目加上复选框和删除按钮。条目需要一个 done 标记,grocery_item 也需要某种方式把用户的操作告诉外面。更新导入、Grocery 结构体和 grocery_item,再添加两个修改列表的小函数:
use tessera_components::{
alignment::CrossAxisAlignment,
boxed::boxed,
button::button,
checkbox::checkbox,
column::column,
icon_button::icon_button,
material_icons::filled,
modifier::ModifierExt as _,
row::row,
spacer::spacer,
surface::surface,
text::text,
text_field::text_field,
text_input::TextInputController,
theme::{MaterialTheme, material_theme},
};
use tessera_ui::{Callback, CallbackWith, Dp, Modifier, State, key, remember, tessera};
#[derive(Clone, PartialEq)]
struct Grocery {
id: u64,
name: String,
done: bool,
}
fn set_done(items: State<Vec<Grocery>>, id: u64, done: bool) {
items.with_mut(|items| {
if let Some(item) = items.iter_mut().find(|item| item.id == id) {
item.done = done;
}
});
}
fn remove(items: State<Vec<Grocery>>, id: u64) {
items.with_mut(|items| items.retain(|item| item.id != id));
}
#[tessera]
fn grocery_item(item: Grocery, on_checked_change: CallbackWith<bool>, on_delete: Callback) {
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
checkbox()
.checked(item.done)
.accessibility_label(item.name.clone())
.on_toggle_shared(on_checked_change);
text(item.name.clone()).modifier(Modifier::new().weight(1.0));
icon_button()
.standard()
.icon(filled::DELETE_SVG)
.accessibility_label(format!("Delete {}", item.name))
.on_click_shared(on_delete);
});
}在 grocery_screen 中,add 操作现在要推入 Grocery { id, name, done: false }。然后把列末尾的 for 循环换成下面这段,为每个条目传入回调:
for item in items.get() {
let id = item.id;
key(id, || {
grocery_item(
item,
move |done| set_done(items, id, done),
move || remove(items, id),
);
});
}现在每一行前面有一个复选框,末尾有一个垃圾桶按钮。勾选条目会打上对勾,点击垃圾桶则删除这一行。
grocery_item 接收要显示的条目和两个回调,通过回调上报事件;列表归 grocery_screen 所有,由它决定事件意味着什么。这种做法叫作状态提升:数据只有一份,复选框和列表始终一致。复选框本身也是受控的:checked 来自数据,on_toggle 把新值交回持有数据的上层。
像 on_delete: Callback 这样的必填回调参数,在调用处可以直接传闭包。这些闭包捕获的是 items(一个可以廉价复制的句柄)和 id,用到的值都归闭包自己所有。删除按钮只显示图标,所以用 accessibility_label 给它一个读屏软件能念出来的名字。状态提升见状态,操作见手势与交互,标注方式见无障碍。
第 5 步:让长列表滚动
加上十几个条目,最后几项就会超出窗口底部:column 会把给它的内容按完整长度全部排出来,一直排到窗口边缘之外。把 for 循环换成 lazy_column,它可以滚动,而且只构建视口内的行。在 tessera_components 的导入中加上 lazy_list::lazy_column,从 tessera_ui 的导入中去掉 key(现在由列表负责 key),然后替换循环:
lazy_column()
.modifier(Modifier::new().fill_max_width().weight(1.0))
.items_from_iter_with_key(
items.get(),
|_, item| item.id,
move |_, item| {
let id = item.id;
grocery_item(
item.clone(),
move |done| set_done(items, id, done),
move || remove(items, id),
);
},
);条目多到放不下时,列表可以用鼠标滚轮或触摸滚动,标题和文本框则固定不动。
滚动列表需要一个有限的高度作为滚动范围。weight(1.0) 把列在标题和输入行之后剩下的高度交给它;这之所以可行,是因为列本身铺满了窗口。第二个参数返回每个条目的 key,列表变化时,行会继续跟随自己的条目。即使有 key,行滚出视口后它的局部状态也会结束;这里的清单数据保存在 grocery_screen 中,所以滚动时每个条目的数据都完好保留。列表与滚动讲解了视口、key 和控制器。
第 6 步:为应用设置主题
应用仍在使用默认的浅色主题。现在给它换一种主色,加粗标题,再加一个深色模式开关。在 app 中保存深色模式标记,用它生成主题,并把标记传给界面:
#[tessera]
pub fn app() {
let dark = remember(|| false);
material_theme()
.theme(move || MaterialTheme::from_seed(Color::from_rgb(0.2, 0.5, 0.25), dark.get()))
.child(move || {
surface()
.modifier(Modifier::new().fill_max_size())
.child(move || {
grocery_screen(dark);
});
});
}grocery_screen 接收这个标记,从主题中读取标题样式,并在标题旁放一个切换按钮:
#[tessera]
fn grocery_screen(dark: State<bool>) {
let title_style = use_context::<MaterialTheme>()
.expect("grocery_screen needs a material_theme ancestor")
.get()
.typography
.headline_small;
let items = remember(Vec::<Grocery>::new);
let next_id = remember(|| 0_u64);
let editor = remember(|| TextInputController::new(Dp(16.0), Some(Dp(24.0))));
let add = Callback::new(move || {
let name = editor.with(|editor| editor.text()).trim().to_string();
if name.is_empty() {
return;
}
let id = next_id.get();
next_id.set(id + 1);
items.with_mut(|items| items.push(Grocery { id, name, done: false }));
editor.with_mut(|editor| editor.set_text(""));
});
column()
.modifier(Modifier::new().fill_max_size().padding_all(Dp(16.0)))
.children(move || {
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
text("Groceries")
.style(title_style)
.modifier(Modifier::new().weight(1.0));
icon_button()
.standard()
.icon(filled::DARK_MODE_SVG)
.selected(dark.get())
.accessibility_label("Dark theme")
.on_click(move || dark.set(!dark.get()));
});
spacer().modifier(Modifier::new().height(Dp(8.0)));
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
boxed().modifier(Modifier::new().weight(1.0)).children(move || {
text_field()
.modifier(Modifier::new().fill_max_width())
.controller(editor)
.label("Add an item")
.on_submit_shared(add);
});
spacer().modifier(Modifier::new().width(Dp(8.0)));
button().on_click_shared(add).child(|| text("Add"));
});
lazy_column()
.modifier(Modifier::new().fill_max_width().weight(1.0))
.items_from_iter_with_key(
items.get(),
|_, item| item.id,
move |_, item| {
let id = item.id;
grocery_item(
item.clone(),
move |done| set_done(items, id, done),
move || remove(items, id),
);
},
);
});
}在 tessera_ui 的导入中加上 Color 和 use_context。按钮、复选框和文本框都换成了绿色调,标题变大了,右上角的月亮按钮可以让整个应用在浅色和深色之间切换。
MaterialTheme::from_seed 从一个种子颜色推导出完整的配色方案,第二个参数决定使用浅色还是深色。主题闭包每次运行都会读取 dark,所以切换标记会重建主题,所有读取主题的组件随之更新。Tessera 不会自动跟随操作系统的深色模式,像这样用状态保存选择,就能把决定权交给用户。在界面内部,use_context::<MaterialTheme>() 读取最近的主题,标题因此用上了主题的 headline_small 样式。Material 3 主题介绍了颜色角色、字体排印和形状。
完成后的应用
完成最后一步后,src/app.rs 的完整内容如下:
src/app.rs
use tessera_components::{
alignment::CrossAxisAlignment,
boxed::boxed,
button::button,
checkbox::checkbox,
column::column,
icon_button::icon_button,
lazy_list::lazy_column,
material_icons::filled,
modifier::ModifierExt as _,
row::row,
spacer::spacer,
surface::surface,
text::text,
text_field::text_field,
text_input::TextInputController,
theme::{MaterialTheme, material_theme},
};
use tessera_ui::{
Callback, CallbackWith, Color, Dp, Modifier, State, remember, tessera, use_context,
};
#[derive(Clone, PartialEq)]
struct Grocery {
id: u64,
name: String,
done: bool,
}
fn set_done(items: State<Vec<Grocery>>, id: u64, done: bool) {
items.with_mut(|items| {
if let Some(item) = items.iter_mut().find(|item| item.id == id) {
item.done = done;
}
});
}
fn remove(items: State<Vec<Grocery>>, id: u64) {
items.with_mut(|items| items.retain(|item| item.id != id));
}
#[tessera]
fn grocery_item(item: Grocery, on_checked_change: CallbackWith<bool>, on_delete: Callback) {
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
checkbox()
.checked(item.done)
.accessibility_label(item.name.clone())
.on_toggle_shared(on_checked_change);
text(item.name.clone()).modifier(Modifier::new().weight(1.0));
icon_button()
.standard()
.icon(filled::DELETE_SVG)
.accessibility_label(format!("Delete {}", item.name))
.on_click_shared(on_delete);
});
}
#[tessera]
pub fn app() {
let dark = remember(|| false);
material_theme()
.theme(move || MaterialTheme::from_seed(Color::from_rgb(0.2, 0.5, 0.25), dark.get()))
.child(move || {
surface()
.modifier(Modifier::new().fill_max_size())
.child(move || {
grocery_screen(dark);
});
});
}
#[tessera]
fn grocery_screen(dark: State<bool>) {
let title_style = use_context::<MaterialTheme>()
.expect("grocery_screen needs a material_theme ancestor")
.get()
.typography
.headline_small;
let items = remember(Vec::<Grocery>::new);
let next_id = remember(|| 0_u64);
let editor = remember(|| TextInputController::new(Dp(16.0), Some(Dp(24.0))));
let add = Callback::new(move || {
let name = editor.with(|editor| editor.text()).trim().to_string();
if name.is_empty() {
return;
}
let id = next_id.get();
next_id.set(id + 1);
items.with_mut(|items| items.push(Grocery { id, name, done: false }));
editor.with_mut(|editor| editor.set_text(""));
});
column()
.modifier(Modifier::new().fill_max_size().padding_all(Dp(16.0)))
.children(move || {
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
text("Groceries")
.style(title_style)
.modifier(Modifier::new().weight(1.0));
icon_button()
.standard()
.icon(filled::DARK_MODE_SVG)
.selected(dark.get())
.accessibility_label("Dark theme")
.on_click(move || dark.set(!dark.get()));
});
spacer().modifier(Modifier::new().height(Dp(8.0)));
row()
.modifier(Modifier::new().fill_max_width())
.cross_axis_alignment(CrossAxisAlignment::Center)
.children(move || {
boxed().modifier(Modifier::new().weight(1.0)).children(move || {
text_field()
.modifier(Modifier::new().fill_max_width())
.controller(editor)
.label("Add an item")
.on_submit_shared(add);
});
spacer().modifier(Modifier::new().width(Dp(8.0)));
button().on_click_shared(add).child(|| text("Add"));
});
lazy_column()
.modifier(Modifier::new().fill_max_width().weight(1.0))
.items_from_iter_with_key(
items.get(),
|_, item| item.id,
move |_, item| {
let id = item.id;
grocery_item(
item.clone(),
move |done| set_done(items, id, done),
move || remove(items, id),
);
},
);
});
}提交代码前,运行 cargo tessera check。它会先检查组件是否只在可以构建界面的地方被调用,然后编译项目。
下一步
到这里,构成 Tessera 应用的大部分概念你都已经用过了。Tessera 的思维方式讲解这些概念背后的模型,状态进一步说明数据归谁所有、界面何时重建。想了解组件库还提供了哪些组件,可以浏览组件 API 参考或在线演示。