Widget 基础
通常,一个应用是由多个组件构成的,这些组件包括了多个窗口、按钮、输入框、标签等控件,或者是由这些控件组成的子组件。
组件(Component),广义来说,是一种封装了特定功能的模块化单元,通常包含逻辑和视图的定义。
组件可以由多个控件(Widget)组成,也可以是其他组件的子组件。它们通常具有独立的生命周期。
组件具有如下特点:
- 模块化:组件是构建用户界面的模块化单元,易于重用和维护。
- 封装性:组件封装了自己的逻辑和视图,减少了与其他组件之间的耦合。
- 组合性:组件可以嵌套其他组件,从而构建复杂的界面。
Makepad Widget 作为 Makepad 框架的一部分,由 makepad-widgets 库预置了 Makepad 框架的一些基本 UI 控件,如按钮、标签、输入框等。
这些控件旨在简化 UI 的构建和管理,使开发者能够快速创建复杂的 UI 组件。
本章不会对 makepad-widgets 库中的所有控件进行一一讲解(这部分内容将在后续应用实践中逐步展开),而是对其共性和公共特点进行描述,让你对 makepad UI 控件的基本工作机制建立一个整体的心智模型,以便更好地应用它们。
Widget 与 Compont 的主要区别
Widget:
- 基础 UI 构建块
- 直接处理事件和绘制
- 关注基础 UI 能力
Component:
- Widget 的组合
- 更高层的抽象
- 关注业务逻辑
- 组合已有 Widget
- 重用性更强
声明式 UI 构建自定义组件
我们先以一个自定义的组件示例开始逐步了解这些内置控件。这个示例来自于 makepad 中的 simple 示例,但是本章会对其略微修改。该示例的最终效果如视频所示:
- 该窗口有三个控件:Button / Lable / TextInput ,每点击一次按钮时,输入框的数字加一。
- 该窗口背景是彩色的,并跟随鼠标滑动而变化。
首先,使用 cargo new simple 来创建一个 crate。
在 Cargo.toml 中引入 makepad-widgets 库的依赖。
1[dependencies]
2# 使用了 rik 分支,因为这个分支比较活跃。
3makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "rik" }
然后请在 src 下创建 lib.rs 和 app.rs 文件。此时你的目录结构如下:
1simple/
2├── Cargo.lock
3├── Cargo.toml
4├── src
5│ ├── app.rs
6│ ├── lib.rs
7│ └── main.rs
在 lib.rs
中添加如下代码:
然后我们在 app.rs 模块中完善我们的代码。
一个基本的 App 就是一个组件,我们该如何定义它呢?先来定义整个组件结构。
1use makepad_widgets::*; // 导入 Makepad Widgets 包
2
3// 定义 live_design 宏,用于声明 UI 组件和布局
4live_design! {
5 App = {{App}} {
6 // 定义 UI 树的根节点
7 ui: <Root>{
8 // TODO
9 }
10 }
11}
12
13// 定义应用程序入口
14app_main!(App);
15
16// 定义 App 结构体,包含 UI 和计数器
17#[derive(Live, LiveHook)]
18pub struct App {
19 #[live]
20 ui: WidgetRef, // UI 组件引用
21 #[rust]
22 counter: usize, // 计数器
23}
24
25// 实现 LiveRegister trait,用于注册 live desin
26impl LiveRegister for App {
27 fn live_register(cx: &mut Cx) {
28 // 注册 Makepad Widgets 的 live design
29 makepad_widgets::live_design(cx);
30 }
31}
32
33// 实现 AppMain 特性,用于处理事件
34impl AppMain for App {
35 fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
36 // 匹配事件并处理
37 self.match_event(cx, event);
38 // 处理 UI 事件
39 self.ui.handle_event(cx, event, &mut Scope::empty());
40 }
41}
代码结构说明如下:
live_design!
宏用于声明 UI 组件和布局。它是我们之前说过的 Live 系统,利用 Rust 宏实现的 Live DSL。以便于在运行时实现对 UI 界面的更改。
app_main!(App)
,这个宏定义了应用程序的入口。因为 makepad 要支持跨平台(包括 Web /iOS/Android/ MacOS/Windows/Linux)应用, app_main!
宏内部包括了支持各个平台的相关入口代码。所以这里用宏,而非简单的 main
函数。
- Rust 结构体
App
使用了 derive(Live,LiveHook)
派生宏,自动为 App
实现了两个 Live 系统相关的 trait : Live
和 LiveHook
。
- 其中字段
ui
为 WidgetRef
类型,实际可以看作是 dyn Widget
,即实现了 Widget
trait 的 UI 控件。这些控件同样可以运行时更新,就用 #[live]
属性标识,该属性会自动为该字段实现一些派生宏,比如为字段创建 LiveId
,调用 LiveHook
方法等。
counter
字段则属于业务逻辑字段,是为了计数,所以这里用 #[rust]
属性标识,告诉 Live 系统,这属于 Rust 范畴而非 UI 控件 ,不参与运行时更新。
Live
和 LiveHook
trait 与 Makepad 控件的 Live 生命周期相关,我们在后面详细介绍。
LiveRegister
trait 则是用于把 App
注册到 Live 系统。
AppMain
trait 定义了 handle_event
方法,它用于给 UI 树传递各种事件,包括鼠标、定时器或滑动事件等。
- 该 trait 对象实际会在
app_main!
中被调用。所以它也是程序入口的一部分。
- 内部调用
ui
的 handle_event
传入的参数 cx
/ event
分别是上下文信息和事件,而 Scope
则是用于组件共享状态,当前 Scope::empty()
代表没有共享状态。
让我们先继续完成这个 App 组件,稍后再深入其背后机制。
Makepad 是声明式 UI 框架,这意味着,只需要声明用户想要的 UI 样式,框架会自动更新 UI 的变化。
1live_design! {
2 // 导入 Makepad theme 和 shaders, 以及 widgets
3 use link::theme::*;
4 use link::shaders::*;
5 use link::widgets::*;
6
7 // 定义 App 组件
8 App = {{App}} {
9 // 定义 UI 树的根节点
10 ui: <Root>{
11 // 定义主窗口
12 main_window = <Window>{
13 // 显示背景
14 show_bg: true
15 width: Fill,
16 height: Fill
17
18
19 // 定义自定义背景绘制
20 draw_bg: {
21 fn pixel(self) -> vec4 {
22 // 获取几何位置
23 let st = vec2(
24 self.geom_pos.x,
25 self.geom_pos.y
26 );
27
28 // 计算颜色,基于 x 和 y 位置及时间
29 let color = vec3(st.x, st.y, abs(sin(self.time)));
30 return vec4(color, 1.0);
31 }
32 }
33 // 定义窗口主体
34 body = <ScrollXYView>{
35 // 布局方向为垂直
36 flow: Down,
37 // 子项间距为10
38 spacing:10,
39 // 对齐方式
40 align: {
41 x: 0.5,
42 y: 0.5
43 },
44 // 按钮组件
45 button1 = <Button> {
46 text: "Hello world"
47 draw_text:{color:#f00} // 文字颜色为红色
48 }
49
50
51 // 文本输入组件
52 label1 = <Label> {
53 draw_text: {
54 color: #f // 文字颜色为白色
55 },
56 text: "Click to count "
57 }
58
59 // 文本输入组件
60 input1 = <TextInput> {
61 width: 100, height: 30
62 text: "Counter: 0 "
63 }
64 }
65 }
66 }
67 }
68}
makepad 通过在 live_design!
宏内部使用 Live DSL
语言来声明 UI 样式。
基本组件和主题
1use link::theme::*;
2use link::shaders::*;
3use link::widgets::*;
这两组 use
导入语句,用于导入基础组件和默认主题。注意,这里 use
关键字是 Live DSL 专用,它一般用于导入其他模块内由 live_design! 定义的Live 脚本。
定义App组件
1// 定义 App 结构体,包含 UI 和计数器
2#[derive(Live, LiveHook)]
3pub struct App {
4 #[live]
5 ui: WidgetRef, // UI 组件引用
6 #[rust]
7 counter: usize, // 计数器
8}
9
10live_design! {
11 App = {{App}} {
12 // 定义 UI 树的根节点
13 ui: <Root>{
14 ...
15 }
16 }
17}
App
组件在 live_design! 中由 App = {{App}}
这样的语法来定义。这里是创建了一个 App 组件实例,而 {{App}}
里面的 App 则对应 Rust 定义的 App 结构体。
App
结构体有两个字段 ui
和 counter
,其中 ui
是表示 UI 组件,所以用 #[live]
属性标注,而 counter
是非 UI 字段,所以用 #[rust]
属性标注。
在 live_design!
DSL 宏中, ui: <Root> { ... }
表示 UI 组件使用 Root 根节点。
Root 组件
Root 组件在 Makepad 中扮演着重要的角色,主要负责管理和协调多个子组件。
1live_design!{
2 RootBase = {{Root}} {}
3}
4
5#[derive(Live, LiveRegisterWidget, WidgetRef)]
6pub struct Root {
7 // 跟踪当前正在绘制状态
8 #[rust] draw_state: DrawStateWrap<DrawState>,
9 // 这是一个 ComponentMap<LiveId, WidgetRef>,用于存储和管理多个窗口
10 // 每个窗口都是一个 Widget,通过 LiveId 进行标识
11 #[rust] windows: ComponentMap<LiveId, WidgetRef>,
12}
13...
该组件通过 LiveId 和 WidgetRef 来管理多个 windows ,并且能跟踪当前绘制状态。
渲染机制
Makepad Widget 采用即时模式(immediate mode)和保留模式(retained mode)相结合的混合模式(Hybird)方式,提供了高度灵活性和性能优化。
- 保留模式是指保存UI元素的状态和结构,只在需要时进行更新,适合静态或变化不太频繁的 UI 元素。
- 即时模式则是每帧重新计算和绘制 UI 元素,提供了更大的灵活性和易于实现动态 UI,适合频繁变化的 UI 元素。
Makepad 采用混合模式,保留了渲染 UI 树的结构状态,平衡性能和灵活性。遍历所有需要更新的窗口和组件,对于即时模式部分,重新计算和绘制,对于保留模式部分,检查是否需要更新,仅更新变化的部分。
Root 组件就是保留模式和即时模式混合的一个组件。
Window 组件
1// 定义 App 组件
2App = {{App}} {
3 // 定义 UI 树的根节点
4 ui: <Root>{
5 // 定义主窗口
6 main_window = <Window>{
7 // 显示背景
8 show_bg: true
9 width: Fill,
10 height: Fill
11
12
13 // 定义自定义背景绘制
14 draw_bg: {
15 fn pixel(self) -> vec4 {
16 ...
17 }
18 }
19 // 定义窗口主体
20 body = <ScrollXYView>{
21 // 布局方向为垂直
22 flow: Down,
23 // 子项间距为10
24 spacing:10,
25 // 对齐方式
26 align: {
27 x: 0.5,
28 y: 0.5
29 },
30 // 按钮组件
31 button1 = <Button> {
32 text: "Hello world"
33 draw_text:{color:#f00} // 文字颜色为红色
34 }
35
36
37 // 文本输入组件
38 label1 = <Label> {
39 draw_text: {
40 color: #f // 文字颜色为白色
41 },
42 text: "Click to count "
43 }
44
45 // 文本输入组件
46 input1 = <TextInput> {
47 width: 100, height: 30
48 text: "Counter: 0 "
49 }
50 }
51 }
52 }
53}
在 Root
组件内部,我们定义了 main_window
主窗口组件 <Window>
实例。
同样,该 <Window>
组件对应于定义在 widgets/window.rs
中的 Rust 结构体 Window
。
在 main_window 实例中,声明了一些基本属性: show_bg
/ width
/ height
,还有一个特别的属性 draw_bg
,该属性特别之处在于以 draw_
为前缀,可以跟随一个 代码块(block) 来指定着色脚本,这里是指定了 fn pixel(self) -> vec4
函数来渲染背景色。
另外 main_window
实例中还定义了 body
组件实例,它对应的是一个滚动视图组件 <ScrollXYView>
,同样,该 body
实例中定义了该组件的一些基本属性和所包含的组件实例:
<Button>
, 按钮实例
<Label>
,标签实例
<TextInput>
,文本输入实例
至此,我们的 Simple UI 声明完毕。接下来,我们要为该 UI 增加指定的业务逻辑。
增加业务逻辑
我们的 Simple UI 的业务逻辑是:点击按钮时,文本输入框内值会变化,展示增加后的计数,每次点击按钮加一。
1// 实现 MatchEvent 特性,用于处理事件
2impl MatchEvent for App {
3 fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
4 // 检查按钮是否被点击
5 // 这里可以直接通过 `id!()`使用 button1 实例,获取被点击 Button
6 // `clicked` 是 Button 组件的方法
7 if self.ui.button(id!(button1)).clicked(&actions) {
8 // 增加计数器
9 log!("BUTTON jk {}", self.counter);
10 self.counter += 1;
11 // 更新标签文本
12 // 同样,通过 `id!()` 获取 input1 文本输入实例
13 let input = self.ui.text_input(id!(input1));
14 // 通过文本输入框组件内置的 `set_text_and_redraw`
15 // 更新文本输入框的内容为新的计数器值,并触发即时重绘。
16 input.set_text_and_redraw(cx, &format!("Counter: {}", self.counter));
17 }
18 }
19}
id!()
宏是获取传入组件实例的 LiveId
。在 makepad 中 Live System 、draw 库 和 Widget 库深度耦合。组件的渲染顺序依赖于 LiveId
。后续章节会深入讲解 Live System 相关内容。
为 App
实现 MatchEvent trait
来实现我们的业务逻辑。该 trait 是在 makepad-draw/src/match_event.rs
中被定义的,它是 Makepad 框架中处理各种事件的核心接口。
该 trait 定义了多个事件处理方法,如 handle_startup
, handle_shutdown
, handle_draw
等。这是将底层平台的各种事件抽象成统一的接口,简化了跨平台开发。
其中 handle_actions
方法用于处理多个 action,默认实现是遍历所有 action 并调用 handle_action
。
在 MatchEvent
trait 中还定义了 match_event
默认实现,它里面会调用 handle_actions
方法。
在我们前面定义的 App 入口事件处理方法 handle_event 中调用了该 match_event 方法 :
1// 实现 AppMain 特性,用于处理事件
2impl AppMain for App {
3 fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
4 // 匹配事件并处理
5 self.match_event(cx, event);
6 // 处理 UI 事件,本例就是 `<Root>`
7 // 这里调用的是实现 Widget trait 时定义的 handle_event 方法
8 self.ui.handle_event(cx, event, &mut Scope::empty());
9 }
10}
这里反映了 Makepad 将应用逻辑和 UI 组件关注点分离的架构决策。应用级别的事件处理(match event
)通常更高层次和抽象。UI 事件处理更关注具体的组件交互。
Action
Action 对于 Makepad 来说是一个重要的概念,用于表示用户界面的交互或状态变化。
在 platform/src/action.rs
中定义了 Action 的本质。
1pub trait ActionTrait: 'static {
2 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
3 fn ref_cast_type_id(&self) -> TypeId where Self: 'static {TypeId::of::<Self>()}
4}
5
6impl<T: 'static + Debug + ?Sized > ActionTrait for T {
7 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result{
8 self.fmt(f)
9 }
10}
11
12generate_any_trait_api!(ActionTrait);
13
14impl Debug for dyn ActionTrait{
15 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result{
16 self.debug_fmt(f)
17 }
18}
19
20pub type Action = Box<dyn ActionTrait>;
21pub type ActionsBuf = Vec<Action>;
22pub type Actions = [Action];
23
24pub trait ActionCast<T> {
25 fn cast(&self) -> T;
26}
27
28impl Cx{
29 pub fn action(&mut self, action: impl ActionTrait){
30 self.new_actions.push(Box::new(action));
31 }
32
33 pub fn extend_actions(&mut self, actions: ActionsBuf){
34 self.new_actions.extend(actions);
35 }
36
37 pub fn map_actions<F, G, R>(&mut self, f: F, g:G) -> R where
38 F: FnOnce(&mut Cx) -> R,
39 G: FnOnce(&mut Cx, ActionsBuf)->ActionsBuf,
40 {
41 ...
42 }
43
44 pub fn capture_actions<F>(&mut self, f: F) -> ActionsBuf where
45 F: FnOnce(&mut Cx),
46 {
47 ...
48 }
49}
这段代码定义了 Makepad 平台中 Action 的核心结构和行为:
ActionTrait
: 可以被视为 Any
trait 的一种扩展或自定义版本,为所有实现了 Debug
trait 的 'static
类型自动实现了 ActionTrait
。ref_cast_type_id
方法提供了类似于 Any::type_id
的功能。
Action
: 实际上是 ActionTrait
的 trait 对象。 Actions
则是该 trait 对象的 数组,它是一个动态大小类型,类似于 [T]
,所以使用的时候用 &Actions
。
Cx
: 则是一个跨平台上下文结构体,它抽象了对 actions
操作的各种方法,屏蔽了不同平台底层的实现。
在 widgets/src/widget.rs
中定义了 Widget
trait 核心接口,来管理实现 Widget 必须要实现的方法。WidgetRef
则是对 dyn Widget
trait 对象的一个封装。
除此之外,还有 WidgetActionTrait
相关的 trait ,它是专门为 Widget 系统设计的 Action 接口,可以看作这是 ActionTrait
的一种特化。这样就允许 Widget 在运行时动态选择和执行不同的 WidgetAction 。
1pub trait WidgetActionTrait: 'static {
2 fn ref_cast_type_id(&self) -> TypeId;
3 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
4 fn box_clone(&self) -> Box<dyn WidgetActionTrait>;
5}
6
7impl<T: 'static + ? Sized + Clone + Debug> WidgetActionTrait for T {
8 fn ref_cast_type_id(&self) -> TypeId {
9 TypeId::of::<T>()
10 }
11
12 fn box_clone(&self) -> Box<dyn WidgetActionTrait> {
13 Box::new((*self).clone())
14 }
15 fn debug_fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result{
16 self.fmt(f)
17 }
18}
19
20impl Debug for dyn WidgetActionTrait{
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result{
22 self.debug_fmt(f)
23 }
24}
25
26generate_any_trait_api!(WidgetActionTrait);
27
28impl Clone for Box<dyn WidgetActionTrait> {
29 fn clone(&self) -> Box<dyn WidgetActionTrait> {
30 self.as_ref().box_clone()
31 }
32}
33
34#[derive(Clone, Debug)]
35pub struct WidgetAction {
36 action: Box<dyn WidgetActionTrait>,
37 pub widget_uid: WidgetUid,
38 pub path: HeapLiveIdPath,
39 pub group: Option<WidgetActionGroup>
40}
41
42
43impl WidgetActionsApi for Actions{
44 fn find_widget_action(&self, widget_uid: WidgetUid) -> Option<&WidgetAction>{
45 for action in self{
46 // 这里会将 action 转为 WidgetAction 来处理
47 if let Some(action) = action.downcast_ref::<WidgetAction>(){
48 if action.widget_uid == widget_uid{
49 return Some(action)
50 }
51 }
52 }
53 None
54 }
55}
因为 WidgetAction
也默认实现了 ActionTrait
,所以这里可以将 action 转为 WidgetAction
Button Widget 实现机制
我们再来看一下 makepad 内置 Button Widget 的实现。
1live_design! {
2 ButtonBase = {{Button}} {}
3}
4
5#[derive(Clone, Debug, DefaultNone)]
6pub enum ButtonAction {
7 None,
8 /// The button was pressed (a "down" event).
9 Pressed(KeyModifiers),
10 /// The button was clicked (an "up" event).
11 Clicked(KeyModifiers),
12 /// The button was released (an "up" event), but should not be considered clicked
13 /// because the mouse/finger was not over the button area when released.
14 Released(KeyModifiers),
15}
16
17#[derive(Live, LiveHook, Widget)]
18pub struct Button {
19 ......
20 // 定义了各种属性字段
21}
22
23impl Widget for Button {
24 fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
25 let uid = self.widget_uid();
26 ...
27 if self.enabled && self.visible {
28 match event.hits(cx, self.draw_bg.area()) {
29 Hit::FingerDown(fe) => {
30 if self.grab_key_focus {
31 cx.set_key_focus(self.draw_bg.area());
32 }
33 cx.widget_action(uid, &scope.path, ButtonAction::Pressed(fe.modifiers));
34 self.animator_play(cx, id!(hover.pressed));
35 }
36 ...
37 }
38 ...
39 }
40 }
41
42 fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
43 if !self.visible {
44 return DrawStep::done();
45 }
46
47 self.draw_bg.begin(cx, walk, self.layout);
48 self.draw_icon.draw_walk(cx, self.icon_walk);
49 self.draw_text
50 .draw_walk(cx, self.label_walk, Align::default(), self.text.as_ref());
51 self.draw_bg.end(cx);
52 DrawStep::done()
53 }
54}
55
56impl Button {
57 pub fn clicked_modifiers(&self, actions: &Actions) -> Option<KeyModifiers> {
58 if let ButtonAction::Clicked(m) = actions.find_widget_action(self.widget_uid()).cast() {
59 Some(m)
60 } else {
61 None
62 }
63 }
64
65 ...
66}
这里只罗列出 Button Widget 的代码结构,而非全部代码。
要实现一个 Widget ,必须要为其实现 Widget
trait ,包括其中两个必须的方法 handle_event
和 draw_walk
。
handle_event
中匹配特定的事件,比如 Hit::FingerDown
。匹配成功则通过 cx.widget_action
生成相应的 WidgetAction 。其中包含了 ButtonAction::Pressed
这个 Action。
draw_walk
方法是 Makepad 框架中用于绘制 Widget 的核心方法。它结合了布局计算和实际绘制操作。
Action 流程总结
当按钮被点击时的实际流程:
- 用户点击按钮:
- 事件传递到应用:
- 事件进入主循环(应用启动时,
app_main!
会开启一个主循环):
- 在当前的事件循环中,这个点击事件被传递给
AppMain::handle_event
。
- UI 树事件处理:
- 事件通过
self.ui.handle_event(cx, event, &mut Scope::empty())
传递给 UI 树。
- 按钮处理事件:
- 事件到达 Button 组件的
handle_event
方法。
- Action 生成:
- Button 识别到点击,立即生成
ButtonAction::Pressed
或 ButtonAction::Clicked
。
- 使用
cx.widget_action(uid, &scope.path, ButtonAction::Clicked(fe.modifiers));
将创建一个 WidgetAction 。
- Action 收集:
- 该 Action 被立即添加到 Cx 的内部 action buffer (
ActionsBuf
)中。
- 事件处理完成:
- 当前事件循环结束:
- Action 处理:
- 在
self.match_event(cx, event)
中,此时 event
会匹配 Event::Actions
类型。
- 状态更新和 UI 刷新:
- 基于处理的 Actions,更新应用状态并触发必要的 UI 更新。
这个流程展示了 Makepad 如何将底层事件转换为高抽象层面的 Actions,并在整个应用中传播。这种设计允许:
- 将事件处理逻辑分散到各个 Widget 中。
- 提供一个统一的机制来处理用户交互。
- 实现复杂的交互模式,如跨组件通信。
- 保持类型安全,同时允许动态行为。
这个系统的优雅之处在于它结合了即时的事件处理和延迟的 action 处理,提供了灵活性和性能的平衡。