Slint 1.0 已发布,标志着项目已顺利从开发阶段“毕业”,可正式用于生产环境。
Slint 曾经被称为 SixtyFPS,特点是既快又小,是一个 Rust 编写的综合性 UI 工具包,可用于为各种操作系统和处理器架构开发 UI,包括 Linux、macOS、Windows、WebAssembly、Blackberry QNX 和裸机。它允许开发人员为嵌入式和桌面应用程序创建原生用户界面。其开发团队希望将 Slint 打造成下一代 GUI 工具包,因此从头开始构建项目时就考虑了可扩展性、包容性、工具性和跨平台兼容性。
上一节对 Slint 做了简单介绍,本节将通过一个例子进一步学习 Slint UI 框架。
介绍
本教程将通过 Slint + Rust 实现一个小游戏:图块记忆配对,以一种有趣的方式向你介绍 Slint UI 框架。本例将把.slint
语言实现 UI 与 Rust 实现的游戏规则结合起来。
游戏由 16 个矩形方块组成的网格。单击图块会显示下面的图标。我们知道总共有 8 个不同的图标,因此每个图块在网格中的某处都有一个相同图标的图块。目标是找到所有图块的配对。用户可以同时揭开两块图块。如果它们不相同,图标将再次被遮盖。如果两个具有相同图标的图块,那么它们已配对。
游戏在运行中的样子大概是这样:
入门
首先,创建一个新的 cargo 项目:
cargo new slint-memory
cd slint-memory
然后我们Cargo.toml
使用以下方式编辑以添加 slint 依赖项cargo add
:
cargo add slint@1.0.0
最后,我们将Slint 文档 中的 hello world 程序复制到的src/main.rs
:
fn main() {
MainWindow::new().unwrap().run().unwrap();
}
slint::slint! {
export component MainWindow inherits Window {
Text {
text: "hello world";
color: green;
}
}
}
我们运行此示例cargo run
,将出现一个带有绿色“Hello World”问候语的窗口。
一个记忆图块
骨架就位后,让我们来看看游戏的第一个元素:记忆图块,它由底层填充色值的矩形背景、图标图像组成。背景矩形的宽和高声明为 64 逻辑像素,并填充了舒缓的蓝色调。这里是px
是 Slint 语言中尺寸大小的单位。
代码如下:
fn main() {
MainWindow::new().unwrap().run().unwrap();
}
slint::slint! {
component MemoryTile inherits Rectangle {
width: 64px;
height: 64px;
background: #3960D5;
Image {
source: @image-url("icons/bus.png");
width: parent.width;
height: parent.height;
}
}
export component MainWindow inherits Window {
MemoryTile {}
}
}
在Rectangle
中,我们放置了一个Image
元素,该元素使用@image-url()
宏加载图标。使用slint!
宏时,路径是相对于 Cargo.toml 所在文件夹的。
注:可以下载Zip 存档并将其解压缩,解压
icons
包含一堆图标。
curl -O https://slint-ui.com/blog/memory-game-tutorial/icons.zip
unzip icons.zip
运行程序,cargo run
屏幕上会出现一个窗口,显示蓝色背景上的公交车图标。
设置图块交互样式
接下来,添加一个像盖子一样的窗帘,当点击时它会打开。通过在Image下方定义两个矩形来实现这种样式和交互。TouchArea元素声明一个透明的矩形区域,允许对用户输入(例如鼠标单击或点击)做出反转动作。它可以将回调传递到单击图块的 MainWindow, 在 MainWindow 中,定义 open_curtain 属性来表示反转动作,它可以控制窗帘的显示和隐藏。这两种状态详见下表:
open_curtain 值: | false | true |
---|---|---|
Left curtain rectangle | 通过将宽度width设置为父宽度的一半来填充左半部分 | 宽度为零使矩形不可见 |
Right curtain rectangle | 通过将x和宽度设置为父宽度的一半来填充右半部分 | 宽度为零使矩形不可见。x向右移动,以在动画时滑动窗帘打开 |
为了使我们的 tile 可扩展,硬编码的图标名称被替换为一个图标属性,该属性可以在实例化元素时从外部设置更新。还需要添加了一个已配对的状态属性,当找到一个配对图块时,将颜色设置成绿色+阴影。将slint!宏中的代码替换为以下内容:
component MemoryTile inherits Rectangle {
callback clicked;
in property <bool> open_curtain;
in property <bool> solved;
in property <image> icon;
height: 64px;
width: 64px;
background: solved ? #34CE57 : #3960D5;
animate background { duration: 800ms; }
Image {
source: icon;
width: parent.width;
height: parent.height;
}
// Left curtain
Rectangle {
background: #193076;
x: 0px;
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
}
// Right curtain
Rectangle {
background: #193076;
x: open_curtain ? parent.width : (parent.width / 2);
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
animate x { duration: 250ms; easing: ease-in; }
}
TouchArea {
clicked => {
// Delegate to the user of this element
// 请注意代码中的`root`指的是组件中最外层的元素。
root.clicked();
}
}
}
export component MainWindow inherits Window {
MemoryTile {
icon: @image-url("icons/bus.png");
clicked => {
self.open_curtain = !self.open_curtain;
}
}
}
运行它会在屏幕上显示一个带有矩形的窗口,单击它会打开并向我们显示公交车图标。随后的点击将关闭并再次打开窗帘。
从单个图块到多个图块
在对单个图块建模之后,然后再看下如何创建的多个图块组成的网格图块。为了让网格成为我们的游戏板,我们需要两个特征:
- 数据模型:这应该是一个数组,其中每个元素都描述了图块数据结构,例如图像的 url、图像是否可见以及该图块是否已配对。
- 一种使用上述标记代码创建多图块实例的方法。
在 Slint 中,可以使用括号声明一个结构数组,以创建一个数据模型。使用for循环来创建同一元素的多个实例。在.slint
中 for..in 循环中声明的数据模型,在数据模型更改时自动更新。我们实例化所有不同的MemoryTile元素,并根据它们的索引将它们放置在一个网格上,并在图块之间留出一点间距。
首先,复制 tile 数据结构定义并将其粘贴到slint!
宏内部的顶部:
// Added:
struct TileData {
image: image,
image_visible: bool,
solved: bool,
}
component MemoryTile inherits Rectangle {
....
....
}
接下来,将宏底部的导出组件 MainWindow inherits Window { ... } 部分替换为以下代码片段:
export component MainWindow inherits Window {
width: 326px;
height: 326px;
in property <[TileData]> memory_tiles: [
{ image: @image-url("icons/at.png") },
{ image: @image-url("icons/balance-scale.png") },
{ image: @image-url("icons/bicycle.png") },
{ image: @image-url("icons/bus.png") },
{ image: @image-url("icons/cloud.png") },
{ image: @image-url("icons/cogs.png") },
{ image: @image-url("icons/motorcycle.png") },
{ image: @image-url("icons/video.png") },
];
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px;
y: floor(i / 4) * 74px;
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
tile.image_visible = !tile.image_visible;
}
}
}
定义了一个数组数据memory_tiles
,然后用for tile[i] in memory_tiles:
遍历数组,并且设置每个图块的属性。
运行它会为我们提供一个显示 8 个图块的窗口,这些图块可以单独打开。
在 Rust 处理图块数据
游戏中的方块应该随机放置。我们需要使用命令为随机化添加rand
依赖项 :
cargo add rand@0.8
首先需要获取用 .slint 语言声明的图块列表,复制一份,然后随机混合一下打乱它。slint 中对于每个一级属性,都会生成一个 getter 和一个 setter 函数——在我们的例子中是get_memory_tiles
和set_memory_tiles
。由于memory_tiles
是.slint中的一个数组,它表示为一个Rc<dyn slint::Model>
,我们无法修改 .slint 生成的数据,但可以从中获取图块数组数据,并将其放入VecModel
实现Model
特征的对象中, VecModel
允许我们进行修改,可以用它来替换静态生成的数据。
修改 main 函数如下:
fn main() {
use slint::Model;
let main_window = MainWindow::new().unwrap();
// Fetch the tiles from the model
let mut tiles: Vec<TileData> = main_window.get_memory_tiles().iter().collect();
// Duplicate them to ensure that we have pairs
tiles.extend(tiles.clone());
// Randomly mix the tiles
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
tiles.shuffle(&mut rng);
// Assign the shuffled Vec to the model property
let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
main_window.set_memory_tiles(tiles_model.into());
main_window.run().unwrap();
}
运行它会在屏幕上显示一个 4 x 4 矩形网格的窗口,单击时可以显示或隐藏图标。现在只缺少最后一个环,即游戏规则。
添加游戏规则逻辑
Slint 的一般理念是,.slint
实现用户界面,业务逻辑用自己喜欢的编程语言实现(比如 Rust )。游戏规则应强制要求最多两块牌的窗帘打开。如果图块匹配,那么我们认为它们已配对并且它们保持打开状态。否则等一会儿,这样玩家就可以记住图标的位置,然后再次关闭它们。
需要对MainWindow进行两处更改:
- 为 MainWindow 添加一种调用 Rust 代码的方法,它应该检查是否已经配对了一对图块。
- 添加一个 Rust 代码可以切换的属性,以禁用进一步的方块交互,以防止玩家打开比允许的更多的方块。不允许作弊!
首先,在 MainWindow 中添加检测是否配对的方法和是否是禁用状态的属性:
export component MainWindow inherits Window {
width: 326px;
height: 326px;
callback check_if_pair_solved(); // Added
in property <bool> disable_tiles; // Added
in-out property <[TileData]> memory_tiles: [
{ image: @image-url("icons/at.png") },
然后,在 MainWindow 中添加以下逻辑代码:
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px;
y: floor(i / 4) * 74px;
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
// old: tile.image_visible = !tile.image_visible;
// new:
if (!root.disable_tiles) {
tile.image_visible = !tile.image_visible;
root.check_if_pair_solved();
}
}
}
在 Rust 方面,可以向回调添加一个处理函数check_if_pair_solved
,它将检查是否打开了两个图块,如果它们匹配,则该solved
属性在模型中设置为 true。如果它们不匹配,则启动一个计时器,该计时器将在一秒钟后关闭它们。当计时器运行时,禁用所有图块,在此期间不能单击任何内容。
// Assign the shuffled Vec to the model property
let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
main_window.set_memory_tiles(tiles_model.clone().into());
let main_window_weak = main_window.as_weak();
main_window.on_check_if_pair_solved(move || {
let mut flipped_tiles =
tiles_model.iter().enumerate().filter(|(_, tile)| tile.image_visible && !tile.solved);
if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
(flipped_tiles.next(), flipped_tiles.next())
{
let is_pair_solved = t1 == t2;
if is_pair_solved {
t1.solved = true;
tiles_model.set_row_data(t1_idx, t1);
t2.solved = true;
tiles_model.set_row_data(t2_idx, t2);
} else {
let main_window = main_window_weak.unwrap();
main_window.set_disable_tiles(true);
let tiles_model = tiles_model.clone();
slint::Timer::single_shot(std::time::Duration::from_secs(1), move || {
main_window.set_disable_tiles(false);
t1.image_visible = false;
tiles_model.set_row_data(t1_idx, t1);
t2.image_visible = false;
tiles_model.set_row_data(t2_idx, t2);
});
}
}
});
main_window.run().unwrap();
完整代码:一个 Slint + Rust 实现的小游戏:记忆图块
[package]
name = "slint-memory"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rand = "0.8"
slint = "1.0.0"
main.rs 中添加代码:
fn main() {
use slint::Model;
let main_window = MainWindow::new().unwrap();
// Fetch the tiles from the model(从 Model中获取图块列表)
let mut tiles: Vec<TileData> = main_window.get_memory_tiles().iter().collect();
// 复制一份图块数据,确保整体上是成对的,因为游戏就要上翻牌来配对图块
tiles.extend(tiles.clone());
// 随机混合一下,就是打乱所有图标
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
tiles.shuffle(&mut rng);
// 将打乱后的 Vec 分配给模型属性
let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
main_window.set_memory_tiles(tiles_model.clone().into());
// 游戏规则:游戏规则应强制要求最多两块牌的图块打开,
// 如果图块匹配,那么我们认为它们已解决并且它们保持打开状态。
// 否则等一会儿,这样玩家就可以记住图标的位置,然后再次关闭它们。
let main_window_weak = main_window.as_weak();
// 在 Rust 方面,我们现在可以向回调添加一个处理程序check_if_pair_solved,它将检查是否打开了两个图块
main_window.on_check_if_pair_solved(move || {
let mut flipped_tiles =
tiles_model.iter().enumerate().filter(|(_, tile)| tile.image_visible && !tile.solved);
if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
(flipped_tiles.next(), flipped_tiles.next())
{
let is_pair_solved = t1 == t2;
if is_pair_solved { // 如果它们2个匹配,则该solved属性在模型中设置为 true
t1.solved = true;
tiles_model.set_row_data(t1_idx, t1);
t2.solved = true;
tiles_model.set_row_data(t2_idx, t2);
} else {
// 如果它们不匹配,则启动一个计时器,该计时器将在一秒钟后关闭它们。当计时器运行时,禁用每个图块,因此在此期间不能单击任何内容。
let main_window = main_window_weak.unwrap();
main_window.set_disable_tiles(true); // 禁止单击
let tiles_model = tiles_model.clone();
slint::Timer::single_shot(std::time::Duration::from_secs(1), move || {
main_window.set_disable_tiles(false);
t1.image_visible = false;
tiles_model.set_row_data(t1_idx, t1);
t2.image_visible = false;
tiles_model.set_row_data(t2_idx, t2);
});
}
}
});
main_window.run().unwrap();
}
// slint!宏构建 slintUI
slint::slint! {
// Added: tile 数据结构定义并将其粘贴到slint!宏内部的顶部:
struct TileData {
image: image,
image_visible: bool,
solved: bool,
}
component MemoryTile inherits Rectangle {
callback clicked;
in property <bool> open_curtain;
in property <bool> solved;
in property <image> icon;
height: 64px;
width: 64px;
background: solved ? #34CE57 : #3960D5;
animate background { duration: 800ms; }
Image {
source: icon;
width: parent.width;
height: parent.height;
}
// 分成左右两半的原因是点击后图块的关闭动画
// Left curtain
Rectangle {
background: #193076;
x: 0px;
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
}
// Right curtain
Rectangle {
background: #193076;
x: open_curtain ? parent.width : (parent.width / 2);
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
animate x { duration: 250ms; easing: ease-in; }
}
TouchArea {
clicked => {
// Delegate to the user of this element
root.clicked();
}
}
}
export component MainWindow inherits Window {
width: 326px;
height: 326px;
callback check_if_pair_solved(); // Added 回调函数:检查两个图块是否打开已配对
in property <bool> disable_tiles; // Added
in property <[TileData]> memory_tiles: [
{ image: @image-url("icons/at.png") },
{ image: @image-url("icons/balance-scale.png") },
{ image: @image-url("icons/bicycle.png") },
{ image: @image-url("icons/bus.png") },
{ image: @image-url("icons/cloud.png") },
{ image: @image-url("icons/cogs.png") },
{ image: @image-url("icons/motorcycle.png") },
{ image: @image-url("icons/video.png") },
];
// 该for tile[i] in memory_tiles:语法声明了一个变量 tile,
// 其中包含数组中一个元素的数据memory_tiles,以及一个变量i,该变量是图块的索引。
// 我们使用i索引根据其行和列计算图块的位置,使用模和整数除法创建 4 x 4 网格。
// 运行它会为我们提供一个显示 8 个图块的窗口,这些图块可以单独打开。
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px + 20px; // 调整坐标,尽量使整体居中
y: floor(i / 4) * 74px + 20px; // 调整坐标,尽量使整体居中
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
// old: tile.image_visible = !tile.image_visible;
// new: 当计时器运行时,禁用每个图块,因此在此期间不能单击任何内容。
if (!root.disable_tiles) {
tile.image_visible = !tile.image_visible;
root.check_if_pair_solved();
}
}
}
}
}
注:项目用到的图标Icons:github.com/huangbqsky/…,
下载后直接解压到项目icons文件夹即可,也可以自己准备一组小图标替换
执行命令:cargo run,运行效果如下图(注:以上所有程序开发均在vs code下完成,依赖slint官方插件)
游戏只是一个学习 Slint + Rust的示例,非常简单,当然自己可以按照游戏真实情况的继续完善,比如添加更多的组件,显示「开始」、「结束」、「再来一局」等按钮,并添加一些动画,让它更像一个游戏。
总结
Slint 是一个 Rust 编写的综合性 UI 工具包,定义和提供一系列方便构建 UI 的组件,使用了声明式编程来简化 UI 的开发,可用于为桌面和嵌入式设备构建原生用户界面。
在本教程中,我们演示了如何将一些内置的 Slint 元素与 Rust 代码结合起来构建一个小游戏。还有许多我们没有讨论的功能,例如布局、小部件或样式。
建议使用以下链接继续深入学习:
-
示例:在 Slint 存储库中,收集了一些演示和示例。这些是学习如何使用许多 Slint 功能的一个很好的资料。
- Todo 示例:这是实现经典用例的示例之一。
- Memory Puzzle:这是此示例中代码的稍微完善的版本。可以在浏览器中播放 wasm 版本。
-
Slint API 文档:主要 Rust 包的参考文档。
-
Slint Interpreter API Docs:Rust crate 的参考文档,允许动态加载
.slint
文件。