Skip to content

界面

手势与交互 ​

响应用户操作,通常就是给组件传一个闭包。闭包修改状态或调用控制器,UI 在下一次重建时读到结果。下面的例子里,按钮让计数加一,上方的文本读取这个计数:

rust
use tessera_components::{button::button, column::column, text::text};
use tessera_ui::{remember, tessera};

#[tessera]
fn counter() {
    let count = remember(|| 0);
    column().children(move || {
        text(format!("Count: {}", count.get()));
        button()
            .on_click(move || count.with_mut(|value| *value += 1))
            .child(|| text("Increment"));
    });
}

本页示例都默认放在应用的 material_theme() 下运行,根组件的配置见 Material 3 主题。文字输入另有专门的页面:文本与文本输入。

编写动作 ​

组件会保存动作闭包,所以闭包必须拥有它捕获的值,并满足 Send + Sync + 'static。实际写法是捕获可复制的 State 句柄,或者把克隆出的、拥有所有权的值移入闭包。状态归谁所有见状态;动作返回后还要继续的工作见后台任务。

大多数动作都写成普通闭包。动作需要单独保存、共享或转发时,使用 Callback 或 CallbackWith。手里已经有这样的句柄时,直接传给对应的设置方法,例如 on_click_shared。

让自定义内容可交互 ​

现有控件能满足需求时,就从现有控件开始。如果只是想换个外观、行为保持常规,可以使用 tessera_components::modifier::ModifierExt 提供的语义交互修饰符:

修饰符含义
clickable触发动作
toggleable修改布尔值
selectable激活带有选中状态的项目
draggable接收以局部物理像素为单位的拖动增量

每个修饰符都有接受配置结构体的 _with 版本。下面用 clickable_with 给自定义目标设置名称和角色,这样它不仅能响应指针,还能获得键盘焦点:

rust
use tessera_components::{
    boxed::boxed,
    modifier::{ClickableArgs, ModifierExt as _},
    text::text,
};
use tessera_ui::{Callback, Dp, Modifier, accesskit::Role, tessera};

#[tessera]
fn save_action(on_save: Callback) {
    boxed()
        .modifier(Modifier::new().padding_all(Dp(12.0)).clickable_with(
            ClickableArgs {
                label: Some("Save document".into()),
                role: Some(Role::Button),
                on_click: on_save,
                ..Default::default()
            },
        ))
        .children(|| text("Save"));
}

一个修饰符就把同一个动作同时接到指针激活、键盘激活和无障碍点击动作上。配置里还可以设置 enabled、外部的 InteractionState 和 FocusRequester。它在修饰符链中的位置决定了点击区域:像这里一样放在内边距外侧,内边距也能点击。顺序的规则见修饰符。

注意

需要键盘操作的自定义控件,请使用 clickable_with 并设置角色、标签或描述。三者都没有的普通 clickable 不会注册自己的焦点目标,键盘用户无法到达它。

toggleable 描述带有开/关值的控件。它把新值传给你的回调,由你保存:

rust
use tessera_components::{
    modifier::{ModifierExt as _, ToggleableArgs},
    row::row,
    text::text,
};
use tessera_ui::{CallbackWith, Dp, Modifier, accesskit::Role, remember, tessera};

#[tessera]
fn wifi_row() {
    let enabled = remember(|| false);
    row()
        .modifier(Modifier::new().padding_all(Dp(12.0)).toggleable_with(
            ToggleableArgs {
                value: enabled.get(),
                on_value_change: CallbackWith::new(move |value| enabled.set(value)),
                role: Some(Role::Switch),
                label: Some("Wi-Fi".into()),
                ..Default::default()
            },
        ))
        .children(|| text("Wi-Fi"));
}

勾选状态和选中状态要分开对待。toggleable 描述的是布尔控件;selectable_with 对普通可选角色发布选中状态,对 Role::RadioButton 则发布勾选状态。名称、角色和状态的细节见无障碍。

draggable 以 DragDelta 报告每次移动,单位是局部物理像素。DraggableArgs 还可以设置锁定方向、开始拖动前的最小移动距离,以及 on_drag_started / on_drag_stopped 回调。滚动容器会处理自己的拖动,见列表与滚动。

根据按下、悬停和焦点改变外观 ​

有时外观要随交互变化,比如目标被按住的时候。把 InteractionState 传给修饰符,并在构建时读取它。修饰符会持续更新悬停、按下、焦点和拖动状态,状态变化时组件随之重建:

rust
use tessera_components::{
    boxed::boxed,
    modifier::{ClickableArgs, InteractionState, ModifierExt as _},
    text::text,
};
use tessera_ui::{Callback, Dp, Modifier, accesskit::Role, remember, tessera};

#[tessera]
fn hold_to_preview() {
    let interaction = remember(InteractionState::new);
    let pressed = interaction.with(|state| state.is_pressed());
    boxed()
        .modifier(Modifier::new().padding_all(Dp(12.0)).clickable_with(
            ClickableArgs {
                label: Some("Preview".into()),
                role: Some(Role::Button),
                interaction_state: Some(interaction),
                on_click: Callback::new(|| {}),
                ..Default::default()
            },
        ))
        .children(move || text(if pressed { "Release to close" } else { "Hold to preview" }));
}

is_hovered()、is_focused() 和 is_dragged() 的用法相同。Material 控件根据这些信息绘制自己的状态层和水波纹,所以它主要用于自定义视觉效果。目标被禁用时,它的交互状态会被重置。

焦点 ​

键盘用户和使用辅助技术的用户都靠焦点在界面中移动。控件和 clickable_with 目标会自动加入焦点遍历;下面的 API 用于在代码中移动焦点,以及编写自定义焦点目标。焦点修饰符来自核心库的 tessera_ui::FocusModifierExt,需要和组件库的修饰符 trait 一起导入。

在代码中移动焦点 ​

先用 remember 让 FocusRequester 保持稳定,把它绑定到目标上,再在动作里调用它:

rust
use tessera_components::{boxed::boxed, button::button, column::column, text::text};
use tessera_ui::{FocusModifierExt as _, FocusRequester, Modifier, remember, tessera};

#[tessera]
fn preview_panel() {
    let requester = remember(FocusRequester::new).get();
    column().children(move || {
        boxed()
            .modifier(Modifier::new().focus_requester(requester).focusable())
            .children(|| text("Preview"));
        button()
            .on_click(move || requester.request_focus())
            .child(|| text("Focus preview"));
    });
}

focusable() 只负责注册焦点目标。真正的自定义控件还要自己提供动作,以及向辅助技术描述它的语义。已经挂载但被布局隐藏的目标会拒绝直接请求。请在动作里或针对某个具体变化请求焦点;每次重建都请求,会覆盖用户自己导航到的位置。

显示焦点 ​

同一个请求器也能告诉你目标的焦点情况:is_focused() 表示是否持有主焦点,has_focus() 则把活动焦点路径也算在内。绘制键盘焦点指示时,在构建期间读取 is_focus_visible()。键盘输入和无障碍焦点操作会显示指示,指针按下会隐藏指示但不释放焦点,代码设置的焦点则沿用最近一次的输入方式。这样焦点框只在键盘操作时出现,使用鼠标和触摸时保持隐藏:

rust
use tessera_components::{
    boxed::boxed,
    modifier::{ClickableArgs, ModifierExt as _},
    text::text,
    theme::MaterialTheme,
};
use tessera_ui::{
    Callback, Color, Dp, FocusRequester, Modifier, accesskit::Role, remember, tessera,
    use_context,
};

#[tessera]
fn open_tile(on_open: Callback) {
    let scheme = use_context::<MaterialTheme>()
        .expect("open_tile requires material_theme")
        .get()
        .color_scheme;
    let requester = remember(FocusRequester::new).get();
    let ring = if requester.is_focus_visible() {
        scheme.secondary
    } else {
        Color::TRANSPARENT
    };
    boxed()
        .modifier(
            Modifier::new()
                .border(Dp(2.0), ring)
                .padding_all(Dp(12.0))
                .clickable_with(ClickableArgs {
                    label: Some("Open".into()),
                    role: Some(Role::Button),
                    focus_requester: Some(requester),
                    on_click: on_open,
                    ..Default::default()
                }),
        )
        .children(|| text("Open"));
}

焦点变化时要执行代码,可以挂上 on_focus_changed,它会收到一个 FocusState。capture_focus() 会把焦点锁定在目标上,直到你调用 free_focus(),或者焦点被强制清除、目标被布局隐藏或移除。

遍历、分组与恢复 ​

焦点遍历使用 FocusManager::current().request_move_focus(direction)。它把移动排到布局之后执行,因此能让惰性内容显示出来。返回的布尔值表示请求已被接受,目标要等排队的移动执行时才获得焦点。想立即遍历,用 move_focus(direction)。

focus_group() 用来建立遍历组,focus_restorer(...) 建立一个会记住上次获得焦点的后代的作用域。浮层和导航应当有意识地选好初始焦点和恢复目标,这样对话框打开时焦点落在合适的位置,关闭后又能回到原处。

键盘输入 ​

大部分键盘行为由控件自带。获得焦点的控件和 clickable_with 目标会响应键盘激活,焦点遍历负责在它们之间移动,文字输入交给输入框。带 on_long_click 的 surface() 还可以通过上下文菜单键和 Shift+F10 触发这个动作。

需要快捷键,或者自定义控件有自己的按键时,实现 KeyboardInputModifierNode,再按下一节的方式挂载。KeyboardInput 携带待处理的 KeyboardEvent 采样,以及事件发生时的修饰键状态。只消费控件真正处理的按键,让无关的按键继续传给其他处理器。

原始输入扩展 ​

原始输入处理器面向自定义控件和输入集成;普通的按钮动作使用前面介绍的闭包。做法是实现专用的节点 trait,再通过 tessera_ui::modifier::ModifierCapabilityExt 挂载:

来自 tessera_ui 的节点 trait挂载方法输入数据
PointerInputModifierNodepush_pointer_inputPointerInput
KeyboardInputModifierNodepush_keyboard_inputKeyboardInput
ImeInputModifierNodepush_ime_inputImeInput
ClipboardInputModifierNodepush_clipboard_inputClipboardInput

每个 trait 都有对应的 on_..._input 方法,并要求 Send + Sync + 'static。能力 trait 另外提供指针、键盘和 IME 的预览钩子,以及一个指针最终钩子。这些都是编写高层修饰符扩展用的底层构件,扩展的写法见自定义布局。

指针处理器要保留 PointerChange::pointer_id、每个采样点的位置及其消费状态,让一批采样中的每一个都保持它被记录时的位置。PointerInput::local_delta 可以把窗口坐标下的位移换算成局部单位。只消费控件真正处理的事件,让滚动和无关按键继续传给其他处理器。

扩展约定见核心 API 参考。