46 个版本 (破坏性)
0.40.0 | 2023年10月10日 |
---|---|
0.38.4 | 2021年11月19日 |
0.38.3 | 2021年10月30日 |
0.38.2 | 2021年7月18日 |
0.12.1 | 2020年12月31日 |
#1142 in GUI
每月 28 次下载
550KB
14K SLoC
RAUI
关于
RAUI 是一个与渲染器无关的 UI 系统,深受 React 的声明式 UI 组合和 UE4 Slate 小部件组件系统的启发。
🗣 发音: RAUI 发音为 "ra"(埃及神)+ "oui"(法语中的“是”) — 音频示例。
RAUI 架构背后的主要思想是将 UI 作为另一种数据源,将其转换为您的首选渲染引擎使用的目标可渲染数据格式。
架构
应用程序
Application
是用户关注的中心点。它执行整个 UI 处理逻辑。在那里,您应用将要处理的控件树,从宿主应用程序向控件发送消息,并接收控件向宿主应用程序发送的信号。
// Coords mapping tell RAUI renderers how to convert coordinates
// between virtual-space and ui-space.
let mapping = CoordsMapping::new(Rect {
left: 0.0,
right: 1024.0,
top: 0.0,
bottom: 576.0,
});
// Application is UI host.
let mut application = Application::new();
// we use setup functions to register component and props mappings for serialization.
application.setup(setup);
// we can also register them at any time one by one.
application.register_component("app", app);
// Widget tree is simply a set of nested widget nodes, usually made with special macros.
let tree = widget! {
(app {
// <named slot name> = ( <widget to put in a slot> )
title = (title_bar: {"Hello".to_owned()})
content = (vertical_box [
(#{"hi"} button: {"Say hi!".to_owned()})
(#{"exit"} button: {"Close".to_owned()})
])
})
};
// some dummy widget tree renderer.
// it reads widget unit tree and transforms it into target format.
let mut renderer = HtmlRenderer::default();
// `apply()` sets new widget tree.
application.apply(tree);
// `render()` calls renderer to perform transformations on processed application widget tree.
if let Ok(output) = application.render(&mapping, &mut renderer) {
println!("* OUTPUT:\n{}", output);
}
// by default application won't process widget tree if nothing was changed.
// "change" is either any widget state change, or new message sent to any widget (messages
// can be sent from application host, for example a mouse click, or from another widget).
application.forced_process();
if let Ok(output) = application.render(&mapping, &mut renderer) {
println!("* OUTPUT:\n{}", output);
}
控件
控件分为三个类别
WidgetNode
- 用作源 UI 树(可以是组件、单元或无的变体)
widget! {
(app {
// <named slot name> = ( <widget to put in a slot> )
title = (title_bar: {"Hello".to_owned()})
content = (vertical_box [
(#{"hi"} button: {"Say hi!".to_owned()})
(#{"exit"} button: {"Close".to_owned()})
])
})
};
WidgetComponent
- 您可以将它们视为虚拟 DOM 节点,它们存储- 指向 组件函数 的指针(处理其数据)
- 唯一的 键(它是控件 ID 的一部分,并将用于告诉系统是否应将其 状态 带到下一次处理运行)
- boxed 可克隆的 属性 数据
- 列出的槽位(简单地说:控件子代)
- 命名槽位(类似于列出的槽位:控件子代,但这些槽位有分配给它们的名称,因此您可以通过名称而不是通过索引来访问它们)
WidgetUnit
- 渲染器使用的基本元素,用于将数据转换为特定渲染引擎所需的渲染数据格式。
widget! {{{
TextBoxNode {
text: "Hello World".to_owned(),
..Default::default()
}
}}};
组件函数
组件函数是静态函数,它将输入数据(属性、状态或两者皆无)转换为输出的小部件树(通常用于在单个简单组件下包装其他组件树,其中最简单的组件返回最终的WidgetUnit
)。它们作为一个转换链协同工作 - 根组件使用自己的属性或状态中的数据将一些属性应用于子组件。
#[derive(PropsData, Debug, Default, Copy, Clone, Serialize, Deserialize)]
struct AppProps {
#[serde(default)]
pub index: usize,
}
fn app(context: WidgetContext) -> WidgetNode {
let WidgetContext {
props, named_slots, ..
} = context;
// easy way to get widgets from named slots.
unpack_named_slots!(named_slots => { title, content });
let index = props.read::<AppProps>().map(|p| p.index).unwrap_or(0);
// we always return new widgets tree.
widget! {
// `#{key}` - provided value gives a unique name to node. keys allows widgets
// to save state between render calls. here we just pass key of this widget.
// `vertical_box` - name of widget component to use, this one is built into RAUI.
// `[...]` - listed widget slots. here we just put previously unpacked named slots.
(#{index} vertical_box [
{title}
{content}
])
}
}
状态
这可能会提出一个问题:"如果我只使用函数而不使用对象来描述如何可视化UI,我如何在每次渲染运行之间保持一些数据?"。为了这个目的,你使用状态。状态是存储在每次处理调用之间的数据,只要给定的组件存在(这意味着:只要组件ID在两次处理调用之间保持相同,以确保你的组件保持相同,你需要使用键 - 如果没有分配键,系统将为你的组件生成一个,但这会使组件可能在任何时候死亡,例如,如果你的常见父组件中的组件子项数量发生变化,你的组件将在没有分配键时更改其ID)。一些额外的说明:当你使用属性向下发送信息,并使用状态在处理调用之间存储组件数据时,你可以使用消息和信号与其他组件和宿主应用程序进行通信!更重要的是,你可以使用钩子来监听组件的生命周期并执行操作。值得注意的是,状态使用属性来存储其数据,因此你可以附加多个钩子,每个钩子使用不同的数据类型作为组件状态,这为组合操作相同组件的不同钩子打开了创意的大门。
#[derive(PropsData, Debug, Default, Copy, Clone, Serialize, Deserialize)]
struct ButtonState {
#[serde(default)]
pub pressed: bool,
}
钩子
钩子用于将常见的组件逻辑放入单独的函数中,这些函数可以在组件和其他钩子中链接(你可以使用它构建一个可重用的依赖链)。通常用于监听生命周期事件,如挂载、更改和卸载,此外,你可以链接钩子以在组件和其他钩子中按顺序执行。
#[derive(MessageData, Debug, Copy, Clone, PartialEq, Eq)]
enum ButtonAction {
Pressed,
Released,
}
fn use_empty(context: &mut WidgetContext) {
context.life_cycle.mount(|_| {
println!("* EMPTY MOUNTED");
});
context.life_cycle.change(|_| {
println!("* EMPTY CHANGED");
});
context.life_cycle.unmount(|_| {
println!("* EMPTY UNMOUNTED");
});
}
// you use life cycle hooks for storing closures that will be called when widget will be
// mounted/changed/unmounted. they exists for you to be able to reuse some common logic across
// multiple components. each closure provides arguments such as:
// - widget id
// - widget state
// - message sender (this one is used to message other widgets you know about)
// - signal sender (this one is used to message application host)
// although this hook uses only life cycle, you can make different hooks that use many
// arguments, even use context you got from the component!
#[pre_hooks(use_empty)]
fn use_button(context: &mut WidgetContext) {
context.life_cycle.mount(|context| {
println!("* BUTTON MOUNTED: {}", context.id.key());
let _ = context.state.write(ButtonState { pressed: false });
});
context.life_cycle.change(|context| {
println!("* BUTTON CHANGED: {}", context.id.key());
for msg in context.messenger.messages {
if let Some(msg) = msg.as_any().downcast_ref::<ButtonAction>() {
let pressed = match msg {
ButtonAction::Pressed => true,
ButtonAction::Released => false,
};
println!("* BUTTON ACTION: {:?}", msg);
let _ = context.state.write(ButtonState { pressed });
let _ = context.signals.write(*msg);
}
}
});
context.life_cycle.unmount(|context| {
println!("* BUTTON UNMOUNTED: {}", context.id.key());
});
}
#[pre_hooks(use_button)]
fn button(mut context: WidgetContext) -> WidgetNode {
let WidgetContext { key, props, .. } = context;
println!("* PROCESS BUTTON: {}", key);
widget! {
(#{key} text_box: {props.clone()})
}
}
幕后发生了什么
- 应用程序在节点上调用
button
button
调用use_button
钩子use_button
调用use_empty
钩子
use_button
逻辑被执行
button
逻辑被执行
布局
RAUI公开了Application::layout()
API,允许使用虚拟到实际坐标映射和自定义布局引擎来执行小部件树定位数据,这些数据随后由自定义UI渲染器用于指定给定组件应该放置的盒子。每次执行布局都会在Application中存储布局数据,你可以随时访问这些数据。有一个DefaultLayoutEngine
以通用方式执行此操作。如果你发现其管道中的某些部分工作方式与你的预期不同,请随时创建你自己的自定义布局引擎!
let mut application = Application::new();
let mut layout_engine = DefaultLayoutEngine;
application.apply(tree);
application.forced_process();
println!(
"* TREE INSPECTION:\n{:#?}",
application.rendered_tree().inspect()
);
if application.layout(&mapping, &mut layout_engine).is_ok() {
println!("* LAYOUT:\n{:#?}", application.layout_data());
}
交互性
RAUI 允许您通过交互引擎简化并自动化与 UI 的交互 - 这只是一个实现 perform_interactions
方法的结构,该方法引用应用程序,您在那里应该做的所有事情就是向小部件发送与用户输入相关的消息。存在 DefaultInteractionsEngine
,它涵盖了小部件导航、按钮和输入字段 - 来自鼠标(或任何单个指针)、键盘和游戏手柄等输入设备的动作。当涉及到 UI 导航时,您可以向默认交互引擎发送原始 NavSignal
消息,尽管您可以随意选择/取消选择小部件,但您仍然可以使用典型的导航操作:向上、向下、向左、向右、上一个标签/屏幕、下一个标签/屏幕,还可以聚焦文本输入并将文本输入更改发送到焦点输入小部件。RAUI 提供的所有交互式小部件组件都会在其钩子中处理所有 NavSignal
操作,因此用户只需要激活它们的导航功能(使用 NavItemActive
单位属性)。想要仅使用默认交互引擎的 RAUI 集成应使用其中组成的此结构,并使用有关进行了什么输入更改的信息调用其 interact
方法。Tetra 集成 crate(《TetraInteractionsEngine》结构)中包含该功能的示例。
注意:交互引擎应使用布局进行指针事件,因此在执行交互之前,请确保您已重新构建布局!
let mut application = Application::new();
// default interactions engine covers typical pointer + keyboard + gamepad navigation/interactions.
let mut interactions = DefaultInteractionsEngine::new();
// we interact with UI by sending interaction messages to the engine.
interactions.interact(Interaction::PointerMove(Vec2 { x: 200.0, y: 100.0 }));
interactions.interact(Interaction::PointerDown(
PointerButton::Trigger,
Vec2 { x: 200.0, y: 100.0 },
));
// navigation/interactions works only if we have navigable items (such as `button`) registered
// in some navigable container (usually containers with `nav_` prefix).
let tree = widget! {
(#{"app"} nav_content_box [
// by default navigable items are inactive which means we have to tell RAUI we activate
// them to interact with them.
(#{"button"} button: {NavItemActive} {
content = (#{"icon"} image_box)
})
])
};
application.apply(tree);
application.process();
let mapping = CoordsMapping::new(Rect {
left: 0.0,
right: 1024.0,
top: 0.0,
bottom: 576.0,
});
application
.layout(&mapping, &mut DefaultLayoutEngine)
.unwrap();
// Since interactions engines require constructed layout to process interactions we have to
// process interactions after we layout the UI.
application.interact(&mut interactions).unwrap();
媒体
-
RAUI + Tetra In-Game
RAUI 与自定义 Material 主题集成到游戏中的示例,使用 Tetra 作为渲染器。 -
RAUI + Tetra todo app
一个使用 Tetra 渲染器和深色主题 Material 组件库的 TODO 应用程序示例。
贡献
任何提高 RAUI 工具集质量的贡献都受到高度重视。
- 如果您有功能请求,请创建一个 Issue 贴文并解释该功能的目标以及为什么需要它及其优缺点。
- 如果您想创建 PR,请从
next
分支创建您的功能分支,以便在获得批准后可以简单地使用 GitHub 合并按钮将其合并。 - 所有更改都分阶段放入
next
分支,新版本由其提交生成,master 被视为稳定/发布分支。 - 更改应通过测试,您可以通过以下方式运行测试:
cargo test --all --features all
。 - 此说明文件是从
lib.rs
文档生成的,可以通过使用cargo readme
重新生成。
里程碑
RAUI 仍处于早期开发阶段,因此请为 v1.0 之前的这些更改做好准备。
- 将 RAUI 集成到公共开源 Rust 游戏。
- 编写文档。
- 编写一本关于如何正确使用RAUI以及提高UI效率的MD书籍。
- Props功能开始看起来更像是一个微型的ECS - 利用这一点,并为它们创建一个自定义分配器,以优化频繁的props创建/克隆(为了澄清:这并不是关于使用实际的ECS来存储props,更多的是考虑ECS原则如何帮助设计高效的props分配器,以避免频繁的props克隆)。
- 实现VDOM diffing算法以优化树重建。
- 找到一个解决方案(或将其作为一个功能)将特例对象数据转换为强类型数据(用于属性和状态)。
- 制作WASM/JS API绑定。
- 制作C API绑定。
现在已完成的事情
- 添加布局支持。
- 添加交互(用户输入)支持。
- 为GGEZ游戏框架创建渲染器。
- 创建基本用户组件。
- 创建基本的Hello World示例应用程序。
- 将共享props与props解耦(不要合并,将共享props放入上下文中)。
- 创建TODO应用程序作为示例。
- 创建游戏内应用程序作为示例。
- 为Oxygengine游戏引擎创建渲染器。
- 添加复杂的导航系统。
- 创建滚动框小部件。
- 添加“即时模式UI”构建器,以提供基于宏的声明性模式UI构建的替代方案(无开销,它与默认使用的声明性宏等效,即时模式和声明性模式小部件可以轻松交流)。
- 添加数据绑定属性类型,以便轻松从应用程序外部修改数据。
- 创建网格渲染器准备好的顶点 + 索引 + 批量缓冲区的细分渲染器。
- 为Tetra游戏框架创建渲染器。
- 从
widget_component!
和widget_hook!
宏规则转换为pre_hooks
和post_hooks
函数属性。 - 添加 derive
PropsData
和MessageData
过程宏,以逐步取代调用implement_props_data!
和implement_message_data!
宏的需求。 - 添加对门户的支持 - 一种将子树“传送”到另一个树节点的方式(对模态和拖放很有用)。
依赖项
~19–30MB
~469K SLoC