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 示例,但是本章会对其略微修改。该示例的最终效果如视频所示:

  1. 该窗口有三个控件:Button / Lable / TextInput ,每点击一次按钮时,输入框的数字加一。
  2. 该窗口背景是彩色的,并跟随鼠标滑动而变化。

首先,使用 cargo new simple 来创建一个 crate。

1cargo new simple

在 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 中添加如下代码:

1pub mod app;

然后我们在 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}

代码结构说明如下:

  1. live_design! 宏用于声明 UI 组件和布局。它是我们之前说过的 Live 系统,利用 Rust 宏实现的 Live DSL。以便于在运行时实现对 UI 界面的更改。
  2. app_main!(App) ,这个宏定义了应用程序的入口。因为 makepad 要支持跨平台(包括 Web /iOS/Android/ MacOS/Windows/Linux)应用, app_main! 宏内部包括了支持各个平台的相关入口代码。所以这里用宏,而非简单的 main 函数。
  3. Rust 结构体 App 使用了 derive(Live,LiveHook) 派生宏,自动为 App 实现了两个 Live 系统相关的 trait : LiveLiveHook
    • 其中字段 uiWidgetRef 类型,实际可以看作是 dyn Widget ,即实现了 Widget trait 的 UI 控件。这些控件同样可以运行时更新,就用 #[live] 属性标识,该属性会自动为该字段实现一些派生宏,比如为字段创建 LiveId ,调用 LiveHook 方法等。
    • counter 字段则属于业务逻辑字段,是为了计数,所以这里用 #[rust] 属性标识,告诉 Live 系统,这属于 Rust 范畴而非 UI 控件 ,不参与运行时更新。
    • LiveLiveHook trait 与 Makepad 控件的 Live 生命周期相关,我们在后面详细介绍。
  4. LiveRegister trait 则是用于把 App 注册到 Live 系统。
  5. AppMain trait 定义了 handle_event 方法,它用于给 UI 树传递各种事件,包括鼠标、定时器或滑动事件等。
    1. 该 trait 对象实际会在 app_main! 中被调用。所以它也是程序入口的一部分。
    2. 内部调用 uihandle_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 结构体有两个字段 uicounter ,其中 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 类型自动实现了 ActionTraitref_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_eventdraw_walk

handle_event 中匹配特定的事件,比如 Hit::FingerDown 。匹配成功则通过 cx.widget_action 生成相应的 WidgetAction 。其中包含了 ButtonAction::Pressed 这个 Action。

draw_walk 方法是 Makepad 框架中用于绘制 Widget 的核心方法。它结合了布局计算和实际绘制操作。

Action 流程总结

当按钮被点击时的实际流程:

  1. 用户点击按钮:
    • 操作系统捕获到这个点击事件。
  2. 事件传递到应用:
    • 操作系统将这个事件传递给 Makepad 应用。
  3. 事件进入主循环(应用启动时, app_main! 会开启一个主循环):
    • 在当前的事件循环中,这个点击事件被传递给 AppMain::handle_event
  4. UI 树事件处理:
    • 事件通过 self.ui.handle_event(cx, event, &mut Scope::empty()) 传递给 UI 树。
  5. 按钮处理事件:
    • 事件到达 Button 组件的 handle_event 方法。
  6. Action 生成:
    • Button 识别到点击,立即生成 ButtonAction::PressedButtonAction::Clicked
    • 使用 cx.widget_action(uid, &scope.path, ButtonAction::Clicked(fe.modifiers)); 将创建一个 WidgetAction 。
  7. Action 收集:
    • 该 Action 被立即添加到 Cx 的内部 action buffer ( ActionsBuf)中。
  8. 事件处理完成:
    • Button 和其他组件完成事件处理。
  9. 当前事件循环结束:
    • handle_event 方法执行完毕。
  10. Action 处理:
    • self.match_event(cx, event) 中,此时 event 会匹配 Event::Actions 类型。
  11. 状态更新和 UI 刷新:
    • 基于处理的 Actions,更新应用状态并触发必要的 UI 更新。

这个流程展示了 Makepad 如何将底层事件转换为高抽象层面的 Actions,并在整个应用中传播。这种设计允许:

  • 将事件处理逻辑分散到各个 Widget 中。
  • 提供一个统一的机制来处理用户交互。
  • 实现复杂的交互模式,如跨组件通信。
  • 保持类型安全,同时允许动态行为。

这个系统的优雅之处在于它结合了即时的事件处理和延迟的 action 处理,提供了灵活性和性能的平衡。