
Rust+Slint 实现动态消息提示框源码分享
一、效果展示


二、源码分享
1、工程结构

2、main.rs
rust
slint::include_modules!();
use slint::{Model, ModelRc, Timer, TimerMode, VecModel};
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
/// 消息条目(Rust 端记录)
struct MessageEntry {
create_time: Instant,
/// 总显示时长(毫秒)
duration_ms: u64,
}
fn main() {
let ui = MainWindow::new().expect("无法创建主窗口");
// ── 消息模型 ──
let model: Rc<VecModel<MessageItem>> = Rc::new(VecModel::from(vec![]));
ui.set_messages(ModelRc::new(model.clone()));
// ── 内部状态 ──
let entries: Rc<RefCell<Vec<MessageEntry>>> = Rc::new(RefCell::new(Vec::new()));
let counter = Rc::new(std::cell::Cell::new(0_i32));
// ── 注册 add-message 回调 ──
{
let model = model.clone();
let entries = entries.clone();
let counter = counter.clone();
ui.on_add_message(move |msg_type, title, content| {
let id = counter.get();
counter.set(id + 1);
let item = MessageItem {
id,
r#type: msg_type,
title,
content,
progress: 0.0,
removing: false,
};
model.push(item);
entries.borrow_mut().push(MessageEntry {
create_time: Instant::now(),
duration_ms: 5000,
});
});
}
// ── 注册 dismiss-message 回调(手动关闭) ──
{
let model = model.clone();
let entries = entries.clone();
ui.on_dismiss_message(move |id| {
// 先标记为 removing(触发出场动画)
let entries_ref = entries.borrow();
if let Some(idx) = model.iter().position(|m| m.id == id) {
let mut item = model.row_data(idx).unwrap();
item.removing = true;
model.set_row_data(idx, item);
}
drop(entries_ref);
// 300ms 后真正移除(等动画完成)
let model_clone = model.clone();
let entries_clone = entries.clone();
slint::Timer::single_shot(Duration::from_millis(350), move || {
if let Some(idx) = model_clone.iter().position(|m| m.id == id) {
model_clone.remove(idx);
entries_clone.borrow_mut().remove(idx);
}
});
});
}
// ── 进度更新定时器:每 50ms 更新一次 ──
{
let model = model.clone();
let entries = entries.clone();
let progress_timer = Timer::default();
progress_timer.start(
TimerMode::Repeated,
Duration::from_millis(50),
move || {
let entries_ref = entries.borrow();
let now = Instant::now();
for (idx, entry) in entries_ref.iter().enumerate() {
let elapsed = now.duration_since(entry.create_time).as_millis() as f64;
let progress = (elapsed / entry.duration_ms as f64).min(1.0);
if let Some(mut item) = model.row_data(idx) {
if (item.progress - progress as f32).abs() > 0.01 {
item.progress = progress as f32;
model.set_row_data(idx, item);
}
}
}
},
);
std::mem::forget(progress_timer);
}
// ── 自动移除定时器:每秒检查 ──
{
let model = model.clone();
let entries = entries.clone();
let cleanup_timer = Timer::default();
cleanup_timer.start(
TimerMode::Repeated,
Duration::from_millis(100),
move || {
let entries_ref = entries.borrow();
let now = Instant::now();
// 找出已过期的消息
let expired: Vec<(i32, usize)> = entries_ref
.iter()
.enumerate()
.filter(|(_, entry)| {
now.duration_since(entry.create_time).as_millis() as u64 >= entry.duration_ms
})
.map(|(idx, entry)| (entry.create_time.elapsed().as_millis() as i32, idx))
.collect();
drop(entries_ref);
// 标记为 removing(触发出场动画)
for (_, idx) in &expired {
if let Some(mut item) = model.row_data(*idx) {
if !item.removing {
item.removing = true;
model.set_row_data(*idx, item);
}
}
}
// 延迟移除已过期的(等动画)
if !expired.is_empty() {
let model_clone = model.clone();
let entries_clone = entries.clone();
let indices: Vec<usize> = expired.iter().map(|(_, idx)| *idx).collect();
slint::Timer::single_shot(Duration::from_millis(350), move || {
let mut entries_mut = entries_clone.borrow_mut();
// 从高到低删除,避免索引偏移
for &idx in indices.iter().rev() {
if idx < model_clone.row_count() {
model_clone.remove(idx);
if idx < entries_mut.len() {
entries_mut.remove(idx);
}
}
}
});
}
},
);
std::mem::forget(cleanup_timer);
}
ui.run().expect("运行 UI 失败");
}
3、build.rs
rust
fn main(){
slint_build::compile("ui/main.slint")
.unwrap()
}
4、main.slint
rust
import { Button, VerticalBox, HorizontalBox, LineEdit, ScrollView } from "std-widgets.slint";
// 消息类型枚举
export enum MessageType {
success,
error,
warning,
info
}
// 消息数据结构
export struct MessageItem {
id: int,
type: MessageType,
title: string,
content: string,
// 进度值 0.0 ~ 1.0
progress: float,
// 是否正在移除(触发退出动画)
removing: bool,
}
// 颜色常量
global MessageTheme {
// 成功 - 绿色
out property <color> success-color: #10b981;
out property <color> success-bg: #ecfdf5;
// 错误 - 红色
out property <color> error-color: #ef4444;
out property <color> error-bg: #fef2f2;
// 警告 - 橙色
out property <color> warning-color: #f59e0b;
out property <color> warning-bg: #fffbeb;
// 信息 - 蓝色
out property <color> info-color: #3b82f6;
out property <color> info-bg: #eff6ff;
}
// 可复用按钮组件
component MsgButton inherits Rectangle {
in property <image> icon-src;
in property <string> label;
in property <color> accent;
callback clicked();
height: 50px;
width: 130px;
border-radius: 25px;
border-width: 2px;
border-color: accent;
background: accent;
opacity: area.has-hover ? 0.8 : 1.0;
HorizontalBox {
padding: 12px;
alignment: center;
spacing: 8px;
Rectangle {
Image {
width: 16px;
height: 16px;
vertical-stretch: 0;
source: icon-src;
colorize: white;
}
}
Text {
text: label;
color: white;
font-size: 16px;
font-weight: 600;
vertical-stretch: 0;
vertical-alignment: center;
}
}
area := TouchArea {
width: 100%;
height: 100%;
clicked => { root.clicked(); }
mouse-cursor: pointer;
}
}
// 单条消息组件
component MessageCard inherits Rectangle {
in property <MessageItem> message;
callback dismiss(int);
width: 380px;
height: 80px;
border-radius: 12px;
// 根据消息类型获取主题色
property <color> accent: message.type == MessageType.success ? MessageTheme.success-color :
message.type == MessageType.error ? MessageTheme.error-color :
message.type == MessageType.warning ? MessageTheme.warning-color :
MessageTheme.info-color;
property <color> bg: message.type == MessageType.success ? MessageTheme.success-bg :
message.type == MessageType.error ? MessageTheme.error-bg :
message.type == MessageType.warning ? MessageTheme.warning-bg :
MessageTheme.info-bg;
// 白色圆角背景(问题2修复:使用更深的边框颜色,确保圆角可见)
background: bg;
border-width: 2px;
border-color: accent;
// 入场/出场动画
animate x { duration: 400ms; easing: cubic-bezier(0.34, 1.56, 0.64, 1); }
animate opacity { duration: 300ms; easing: ease-in-out; }
opacity: message.removing ? 0.0 : 1.0;
// 图标区域
Rectangle {
x: 12px;
y: 16px;
width: 48px;
height: 48px;
border-radius: 24px;
background: accent;
// 使用 SVG 图标(问题1修复:用图片替代文字)
Image {
x: 12px;
y: 12px;
width: 24px;
height: 24px;
source: message.type == MessageType.success ? @image-url("../images/icon-success.svg") :
message.type == MessageType.error ? @image-url("../images/icon-error.svg") :
message.type == MessageType.warning ? @image-url("../images/icon-warning.svg") :
@image-url("../images/icon-info.svg");
}
}
// 文字内容区域
VerticalBox {
x: 72px;
y: 12px;
width: parent.width - 108px;
height: 56px;
Text {
text: message.title;
font-size: 15px;
font-weight: 600;
color: #1f2937;
overflow: elide;
}
Text {
text: message.content;
font-size: 13px;
color: #6b7280;
overflow: elide;
}
}
// 关闭按钮
Rectangle {
x: parent.width - 32px;
y: 8px;
width: 24px;
height: 24px;
border-radius: 12px;
property <bool> touched: close-btn.has-hover;
background: touched ? #00000015 : transparent;
Text {
text: "x";
font-size: 14px;
font-weight: 700;
color: touched ? #1f2937 : #9ca3af;
horizontal-alignment: center;
vertical-alignment: center;
}
close-btn := TouchArea {
width: 100%;
height: 100%;
clicked => { dismiss(message.id); }
mouse-cursor: pointer;
}
}
// ======== 进度条 ========
Rectangle {
x: 12px;
y: root.height - 6px;
width: (root.width - 24px) * (1.0 - message.progress);
height: 3px;
border-radius: 1.5px;
background: accent;
animate width { duration: 100ms; easing: linear; }
}
}
export component MainWindow inherits Window {
title: "高级消息提示框";
preferred-width: 1000px;
preferred-height: 700px;
// 消息列表
in property <[MessageItem]> messages:[
{ id: 0, type: MessageType.success, title: "操作成功", content: "您的更改已保存并同步到云端。", progress: 0.0, removing: false },
{ id: 1, type: MessageType.error, title: "操作失败", content: "由于网络问题,您的更改未能保存。", progress: 0.0, removing: false },
{ id: 2, type: MessageType.warning, title: "警告", content: "您的操作可能存在风险,请谨慎处理。", progress: 0.0, removing: false },
];
// 回调
callback add-message(MessageType, string, string);
callback dismiss-message(int);
// 渐变背景
background: @linear-gradient(135deg, #667eea 0%, #764ba2 100%);
// 主布局
VerticalBox {
padding: 40px;
// 标题
Text {
// text: "高级消息提示框演示";
font-size: 32px;
font-weight: 700;
color: white;
horizontal-alignment: center;
}
Rectangle { height: 20px; }
// 控制区域
Rectangle {
height: 120px;
border-radius: 16px;
background: #ffffff1a;
clip: true;
// 毛玻璃效果模拟
border-width: 1px;
border-color: #ffffff33;
HorizontalBox {
padding: 20px;
alignment: space-around;
MsgButton {
icon-src: @image-url("../images/btn-success.svg");
label: "成功";
accent: #10b981;
clicked => { add-message(MessageType.success, "操作成功", "您的更改已保存并同步到云端。"); }
}
MsgButton {
icon-src: @image-url("../images/btn-error.svg");
label: "错误";
accent: #ef4444;
clicked => { add-message(MessageType.error, "发生错误", "无法连接到服务器,请检查网络。"); }
}
MsgButton {
icon-src: @image-url("../images/btn-warning.svg");
label: "警告";
accent: #f59e0b;
clicked => { add-message(MessageType.warning, "注意", "存储空间即将用尽,请及时清理。"); }
}
MsgButton {
icon-src: @image-url("../images/btn-info.svg");
label: "信息";
accent: #3b82f6;
clicked => { add-message(MessageType.info, "系统提示", "新版本 v2.0 已发布,点击查看详情。"); }
}
}
}
Rectangle { height: 30px; }
}
// 消息提示框容器(右上角,带滚动)
sv := ScrollView {
x: parent.width - 400px;
y: 20px;
width: 380px;
height: parent.height - 40px;
// 自动滚动到底部
viewport-y: sv.visible-height - sv.viewport-height;
vertical-scrollbar-policy: always-off;
VerticalLayout {
spacing: 12px;
alignment: start;
for msg in messages : MessageCard {
message: msg;
dismiss(id) => { dismiss-message(id); }
}
}
}
}
5、Cargo.toml
rust
[package]
name = "modbus-debug-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
slint = "1.17.1"
[build-dependencies]
slint-build = "1.17.1"
三、实现原理
本项目基于 Rust + Slint 构建,核心思路是用 Rust 管理业务逻辑与数据模型,用 Slint 负责界面渲染与动画,两者通过 Slint 自动生成的类型安全接口进行通信。整体实现可分为以下几个关键部分:
1、数据模型与类型绑定
在 main.slint 中定义了 MessageItem 结构体,包含 id、type、title、content、progress、removing 六个字段,分别用于标识消息的唯一编号、类型、标题、内容、进度值以及是否正在移除。同时通过 export enum MessageType 定义了 success、error、warning、info 四种消息类型。
Rust 端通过 slint::include_modules!() 宏将 Slint 编译生成的代码引入,从而可以直接使用 MainWindow、MessageItem 等类型。借助 VecModel<MessageItem> 作为消息列表的数据模型,并通过 ui.set_messages(ModelRc::new(model.clone())) 将模型绑定到界面,实现 Rust 与 Slint 之间的数据共享。
2、消息的添加与回调机制
界面上的四个按钮(成功、错误、警告、信息)通过 clicked 信号触发 add-message 回调。Rust 端通过 ui.on_add_message(...) 注册回调闭包,接收消息类型、标题和内容三个参数:
- 使用
counter自增生成消息的唯一id; - 构造
MessageItem并push到VecModel中; - 同时在
entries向量中记录该消息的创建时间Instant::now()和显示时长duration_ms(这里设为 5000ms)。
由于 VecModel 是响应式的,push 操作会立即触发 Slint 界面的 for msg in messages 循环重新求值,从而自动渲染出新的消息卡片。
3、进度条更新机制
进度条用于展示消息的剩余显示时间。Rust 端创建了一个 Timer,以 50ms 为周期重复触发:
- 遍历
entries中所有消息,计算当前时间与创建时间的差值elapsed; - 用
elapsed / duration_ms得到 0.0 ~ 1.0 的进度值progress; - 当进度变化超过 0.01 时,通过
model.set_row_data(idx, item)更新对应消息的progress字段。
Slint 端进度条通过 width: (root.width - 24px) * (1.0 - message.progress) 绑定进度值,并配合 animate width { duration: 100ms; } 实现平滑的进度条收缩动画。
4、消息的自动移除与出场动画
当消息的存活时间达到 duration_ms 后,自动移除定时器(每 100ms 检查一次)会将其标记为过期:
- 先找出所有已过期的消息索引;
- 将对应消息的
removing字段设为true,触发 Slint 端的出场动画(opacity渐隐 +x位移动画); - 等待 350ms(动画时长)后,通过
Timer::single_shot延迟移除该消息,从高到低删除索引以避免偏移。
手动关闭的逻辑类似:用户点击消息卡片的关闭按钮时,触发 dismiss-message 回调,同样先标记 removing 再延迟移除,保证出场动画完整播放。
5、界面渲染与动画实现
Slint 端通过声明式语法完成界面布局与动画:
- 消息卡片
MessageCard根据message.type动态选择主题色(成功绿、错误红、警告橙、信息蓝); - 入场动画 :卡片默认从右侧滑入,通过
animate x和animate opacity实现弹性缓动效果; - 出场动画 :当
removing为true时,卡片透明度渐变为 0 并向右滑出; - 滚动容器 :消息列表放在
ScrollView中,并通过viewport-y: sv.visible-height - sv.viewport-height实现自动滚动到底部,保证新消息始终可见。
6、整体流程
#mermaid-svg-pyGYpGhzddk5yLgh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pyGYpGhzddk5yLgh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pyGYpGhzddk5yLgh .error-icon{fill:#552222;}#mermaid-svg-pyGYpGhzddk5yLgh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pyGYpGhzddk5yLgh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pyGYpGhzddk5yLgh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pyGYpGhzddk5yLgh .marker.cross{stroke:#333333;}#mermaid-svg-pyGYpGhzddk5yLgh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pyGYpGhzddk5yLgh p{margin:0;}#mermaid-svg-pyGYpGhzddk5yLgh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pyGYpGhzddk5yLgh .cluster-label text{fill:#333;}#mermaid-svg-pyGYpGhzddk5yLgh .cluster-label span{color:#333;}#mermaid-svg-pyGYpGhzddk5yLgh .cluster-label span p{background-color:transparent;}#mermaid-svg-pyGYpGhzddk5yLgh .label text,#mermaid-svg-pyGYpGhzddk5yLgh span{fill:#333;color:#333;}#mermaid-svg-pyGYpGhzddk5yLgh .node rect,#mermaid-svg-pyGYpGhzddk5yLgh .node circle,#mermaid-svg-pyGYpGhzddk5yLgh .node ellipse,#mermaid-svg-pyGYpGhzddk5yLgh .node polygon,#mermaid-svg-pyGYpGhzddk5yLgh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pyGYpGhzddk5yLgh .rough-node .label text,#mermaid-svg-pyGYpGhzddk5yLgh .node .label text,#mermaid-svg-pyGYpGhzddk5yLgh .image-shape .label,#mermaid-svg-pyGYpGhzddk5yLgh .icon-shape .label{text-anchor:middle;}#mermaid-svg-pyGYpGhzddk5yLgh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pyGYpGhzddk5yLgh .rough-node .label,#mermaid-svg-pyGYpGhzddk5yLgh .node .label,#mermaid-svg-pyGYpGhzddk5yLgh .image-shape .label,#mermaid-svg-pyGYpGhzddk5yLgh .icon-shape .label{text-align:center;}#mermaid-svg-pyGYpGhzddk5yLgh .node.clickable{cursor:pointer;}#mermaid-svg-pyGYpGhzddk5yLgh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pyGYpGhzddk5yLgh .arrowheadPath{fill:#333333;}#mermaid-svg-pyGYpGhzddk5yLgh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pyGYpGhzddk5yLgh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pyGYpGhzddk5yLgh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pyGYpGhzddk5yLgh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pyGYpGhzddk5yLgh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pyGYpGhzddk5yLgh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pyGYpGhzddk5yLgh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pyGYpGhzddk5yLgh .cluster text{fill:#333;}#mermaid-svg-pyGYpGhzddk5yLgh .cluster span{color:#333;}#mermaid-svg-pyGYpGhzddk5yLgh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pyGYpGhzddk5yLgh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pyGYpGhzddk5yLgh rect.text{fill:none;stroke-width:0;}#mermaid-svg-pyGYpGhzddk5yLgh .icon-shape,#mermaid-svg-pyGYpGhzddk5yLgh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pyGYpGhzddk5yLgh .icon-shape p,#mermaid-svg-pyGYpGhzddk5yLgh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pyGYpGhzddk5yLgh .icon-shape .label rect,#mermaid-svg-pyGYpGhzddk5yLgh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pyGYpGhzddk5yLgh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pyGYpGhzddk5yLgh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pyGYpGhzddk5yLgh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
用户点击按钮
触发 add-message 回调
Rust 构造 MessageItem 并 push 到 VecModel
Slint 自动渲染消息卡片
进度定时器每 50ms 更新 progress
进度条宽度实时收缩
存活时间 >= 5000ms?
标记 removing = true
触发出场动画(渐隐 + 位移)
350ms 后从模型中移除
通过以上机制,Rust 负责数据与定时逻辑,Slint 负责渲染与动画,两者各司其职、通过类型安全的绑定高效协作,最终实现了流畅的动态消息提示效果。
