
Rust+Slint 实现翻页时钟源码分享
一、效果展示


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

2、main.rs
slint::include_modules!();
use slint::{Timer, TimerMode};
use std::rc::Rc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// 读取当前系统时间,返回6个数字字符串 [H1, H2, M1, M2, S1, S2]
fn current_digits() -> [String; 6] {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO);
let total_secs = now.as_secs() + 8*60*60;
let hours = (total_secs / 3600) % 24;
let minutes = (total_secs / 60) % 60;
let seconds = total_secs % 60;
[
format!("{}", hours / 10), format!("{}", hours % 10),
format!("{}", minutes / 10), format!("{}", minutes % 10),
format!("{}", seconds / 10), format!("{}", seconds % 10),
]
}
/// 翻页动画:Rust Timer 逐帧驱动 flap-scale 从 1.0 → 0.0
fn flip_digit(flap: &Rc<dyn Fn(f32)>) {
flap(1.0);
const FRAMES: u32 = 22;
const FRAME_MS: u64 = 16;
for i in 1..=FRAMES {
let scale = (i as f32 / FRAMES as f32).powi(2); // ease-in
let set_value = 1.0 - scale;
let flap_clone = Rc::clone(flap);
Timer::single_shot(Duration::from_millis(FRAME_MS * i as u64), move || {
flap_clone(set_value);
});
}
}
macro_rules! anim_setter {
($ui:expr, $method:ident) => {{
let ui_weak = $ui.as_weak();
Rc::new(move |v: f32| {
if let Some(ui) = ui_weak.upgrade() { ui.$method(v); }
}) as Rc<dyn Fn(f32)>
}};
}
fn main() {
let ui = MainWindow::new().expect("无法创建主窗口");
// 初始化:显示当前时间,flap-value 与 value 相同(初始无翻页)
let initial = current_digits();
let setters = [
|ui: &MainWindow, v: &str| { ui.set_digit1_value(v.into()); ui.set_digit1_flap(v.into()); },
|ui: &MainWindow, v: &str| { ui.set_digit2_value(v.into()); ui.set_digit2_flap(v.into()); },
|ui: &MainWindow, v: &str| { ui.set_digit3_value(v.into()); ui.set_digit3_flap(v.into()); },
|ui: &MainWindow, v: &str| { ui.set_digit4_value(v.into()); ui.set_digit4_flap(v.into()); },
|ui: &MainWindow, v: &str| { ui.set_digit5_value(v.into()); ui.set_digit5_flap(v.into()); },
|ui: &MainWindow, v: &str| { ui.set_digit6_value(v.into()); ui.set_digit6_flap(v.into()); },
];
for i in 0..6 {
setters[i](&ui, &initial[i]);
}
// 构建每个位的 flap-scale setter
let flaps: [Rc<dyn Fn(f32)>; 6] = [
anim_setter!(ui, set_digit1_anim),
anim_setter!(ui, set_digit2_anim),
anim_setter!(ui, set_digit3_anim),
anim_setter!(ui, set_digit4_anim),
anim_setter!(ui, set_digit5_anim),
anim_setter!(ui, set_digit6_anim),
];
let ui_weak = ui.as_weak();
// 启动动画:500ms 后六个数字依次翻页(初始 flap-value == value,视觉上仍为同数字翻页)
let ui_init = ui_weak.clone();
let flaps_init = flaps.clone();
Timer::single_shot(Duration::from_millis(500), move || {
if ui_init.upgrade().is_none() { return; }
for (i, delay) in [0u64, 80, 160, 240, 320, 400].iter().enumerate() {
let flap = Rc::clone(&flaps_init[i]);
Timer::single_shot(Duration::from_millis(*delay), move || {
flip_digit(&flap);
});
}
});
// 每秒更新时间,仅在数字变化时触发翻页
let mut prev = initial;
let ui_loop = ui_weak.clone();
let flaps_loop = flaps;
let loop_timer = Timer::default();
loop_timer.start(TimerMode::Repeated, Duration::from_secs(1), move || {
let new_digits = current_digits();
if let Some(ui) = ui_loop.upgrade() {
for i in 0..6 {
if new_digits[i] != prev[i] {
// 关键:先设置 flap-value = 旧值(纸页),再设置 value = 新值(背景)
// 然后触发翻页动画
let old_val = prev[i].clone();
let new_val = new_digits[i].clone();
match i {
0 => { ui.set_digit1_flap(old_val.into()); ui.set_digit1_value(new_val.into()); }
1 => { ui.set_digit2_flap(old_val.into()); ui.set_digit2_value(new_val.into()); }
2 => { ui.set_digit3_flap(old_val.into()); ui.set_digit3_value(new_val.into()); }
3 => { ui.set_digit4_flap(old_val.into()); ui.set_digit4_value(new_val.into()); }
4 => { ui.set_digit5_flap(old_val.into()); ui.set_digit5_value(new_val.into()); }
5 => { ui.set_digit6_flap(old_val.into()); ui.set_digit6_value(new_val.into()); }
_ => {}
}
flip_digit(&flaps_loop[i]);
}
}
}
prev = new_digits;
});
ui.run().expect("运行 UI 失败");
}
3、build.rs
fn main(){
slint_build::compile("ui/main.slint")
.unwrap()
}
4、main.slint
// ==================== 翻页数字组件 ====================
component FlipDigit {
in property <string> value: "0"; // 新值(背景显示,翻页完成后露出)
in property <string> flap-value: "0"; // 旧值(翻页纸页显示,翻走后消失)
in property <length> digit-width: 80px;
in property <length> digit-height: 130px;
// 动画属性:Rust 逐帧驱动 1.0 → 0.0
in property <float> flap-scale: 1.0;
// ==================== 卡片容器 ====================
Rectangle {
width: root.digit-width;
height: root.digit-height;
border-radius: 10px;
background: #1c1c1e;
// 底层:显示新值(翻页完成后完全露出)
Text {
text: root.value;
font-size: root.digit-height * 0.82;
font-weight: 700;
color: #ff9f0a;
horizontal-alignment: center;
vertical-alignment: center;
width: parent.width;
height: parent.height;
}
// 翻页纸页:显示旧值,从顶部往下翻走
Rectangle {
width: root.digit-width;
height: root.digit-height;
border-radius: 10px;
background: #2c2c2e;
transform-origin: { x: parent.width / 2, y: 0 };
transform-scale-y: root.flap-scale * 100%;
// 纸页上显示旧值
Text {
text: root.flap-value;
font-size: root.digit-height * 0.82;
font-weight: 700;
color: #ff9f0a;
horizontal-alignment: center;
vertical-alignment: center;
width: parent.width;
height: parent.height;
}
// 翻页底边阴影
Rectangle {
y: parent.height - 12px;
height: 12px;
width: parent.width;
background: #00000040;
}
}
}
}
// ==================== 主窗口 ====================
export component MainWindow inherits Window {
title: "翻页时钟";
preferred-width: 620px;
preferred-height: 320px;
background: #0a0a0a;
// 6个新值
in property <string> digit1-value: "0";
in property <string> digit2-value: "0";
in property <string> digit3-value: "0";
in property <string> digit4-value: "0";
in property <string> digit5-value: "0";
in property <string> digit6-value: "0";
// 6个旧值(翻页纸页显示)
in property <string> digit1-flap: "0";
in property <string> digit2-flap: "0";
in property <string> digit3-flap: "0";
in property <string> digit4-flap: "0";
in property <string> digit5-flap: "0";
in property <string> digit6-flap: "0";
// 6个 flap-scale:Rust 逐帧驱动
in property <float> digit1-anim: 1.0;
in property <float> digit2-anim: 1.0;
in property <float> digit3-anim: 1.0;
in property <float> digit4-anim: 1.0;
in property <float> digit5-anim: 1.0;
in property <float> digit6-anim: 1.0;
VerticalLayout {
alignment: center;
Text {
text: "FLIP CLOCK";
font-size: 11px;
color: #555555;
horizontal-alignment: center;
letter-spacing: 4px;
height: 22px;
vertical-alignment: center;
}
HorizontalLayout {
alignment: center;
spacing: 8px;
height: 130px;
FlipDigit { value: digit1-value; flap-value: digit1-flap; flap-scale: digit1-anim; }
FlipDigit { value: digit2-value; flap-value: digit2-flap; flap-scale: digit2-anim; }
VerticalLayout {
alignment: center;
spacing: 22px;
width: 20px;
Rectangle { width: 9px; height: 9px; border-radius: 5px; background: #ff9f0a; }
Rectangle { width: 9px; height: 9px; border-radius: 5px; background: #ff9f0a; }
}
FlipDigit { value: digit3-value; flap-value: digit3-flap; flap-scale: digit3-anim; }
FlipDigit { value: digit4-value; flap-value: digit4-flap; flap-scale: digit4-anim; }
VerticalLayout {
alignment: center;
spacing: 22px;
width: 20px;
Rectangle { width: 9px; height: 9px; border-radius: 5px; background: #ff9f0a; }
Rectangle { width: 9px; height: 9px; border-radius: 5px; background: #ff9f0a; }
}
FlipDigit { value: digit5-value; flap-value: digit5-flap; flap-scale: digit5-anim; }
FlipDigit { value: digit6-value; flap-value: digit6-flap; flap-scale: digit6-anim; }
}
Text {
text: digit1-value + digit2-value + " : " + digit3-value + digit4-value + " : " + digit5-value + digit6-value;
font-size: 13px;
color: #444444;
horizontal-alignment: center;
height: 26px;
vertical-alignment: center;
}
}
}
5、Cargo.toml
[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 实现了一个翻页时钟,核心思路是:用 Slint 声明式 UI 描述“翻页卡片”的静态结构,用 Rust 的 Timer 逐帧驱动动画属性,从而模拟出纸页从上往下翻走的视觉效果。下面从数据流、动画机制、UI 结构三个层面展开说明。
1、整体数据流
整个程序围绕 6 个数字位(时 2 位、分 2 位、秒 2 位)运转,每个数字位都对应一组“新值 / 旧值 / 动画进度”三个属性:
系统时间 → current_digits() → 6 个数字字符串
→ 每秒 Timer 对比新旧值
→ 数字变化时:flap-value = 旧值,value = 新值
→ 触发 flip_digit() 逐帧驱动 flap-scale 1.0 → 0.0
→ Slint 根据 flap-scale 缩放纸页,完成翻页
value:新数字,作为翻页完成后的背景显示;flap-value:旧数字,显示在“纸页”上,翻页过程中被翻走;flap-scale:纸页的纵向缩放比例,从 1.0(完全展开)渐变到 0.0(完全翻走)。
2、时间读取与数字拆分
current_digits() 负责读取系统时间并拆成 6 个字符:
fn current_digits() -> [String; 6] {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO);
let total_secs = now.as_secs() + 8*60*60; // 东八区偏移
let hours = (total_secs / 3600) % 24;
let minutes = (total_secs / 60) % 60;
let seconds = total_secs % 60;
[
format!("{}", hours / 10), format!("{}", hours % 10),
format!("{}", minutes / 10), format!("{}", minutes % 10),
format!("{}", seconds / 10), format!("{}", seconds % 10),
]
}
关键点:
- 通过
SystemTime::now()获取自 UNIX 纪元以来的秒数,加上8*60*60得到东八区时间; - 用整除和取模分别拆出十位与个位,例如
hours / 10得到十位、hours % 10得到个位; - 返回
[String; 6]数组,正好对应 UI 中的 6 个FlipDigit组件。
3、翻页动画的驱动机制
翻页动画的核心是 flip_digit() 函数,它用 Rust 的 Timer::single_shot 逐帧驱动 flap-scale 从 1.0 渐变到 0.0:
fn flip_digit(flap: &Rc<dyn Fn(f32)>) {
flap(1.0);
const FRAMES: u32 = 22;
const FRAME_MS: u64 = 16;
for i in 1..=FRAMES {
let scale = (i as f32 / FRAMES as f32).powi(2); // ease-in
let set_value = 1.0 - scale;
let flap_clone = Rc::clone(flap);
Timer::single_shot(Duration::from_millis(FRAME_MS * i as u64), move || {
flap_clone(set_value);
});
}
}
实现要点:
- 22 帧、每帧 16ms,总时长约 352ms,形成流畅的翻页动画;
- 使用
(i / FRAMES).powi(2)做 ease-in 缓动,让纸页先慢后快,更接近真实翻页手感; - 每一帧都通过
Timer::single_shot延迟触发,把flap-scale的新值写回 Slint 属性; flap是一个Rc<dyn Fn(f32)>闭包,由anim_setter!宏生成,负责调用 Slint 生成的 setter 方法。
anim_setter! 宏的作用是把 Slint 组件的弱引用和 setter 方法封装成一个可调用的闭包:
macro_rules! anim_setter {
($ui:expr, $method:ident) => {{
let ui_weak = $ui.as_weak();
Rc::new(move |v: f32| {
if let Some(ui) = ui_weak.upgrade() { ui.$method(v); }
}) as Rc<dyn Fn(f32)>
}};
}
使用弱引用 as_weak() 避免循环引用导致的内存泄漏;闭包被包装成 Rc<dyn Fn(f32)>,方便在 Timer 回调中克隆和传递。
4、每秒刷新与翻页触发
主循环使用 Timer::default() 创建重复定时器,每秒执行一次:
let loop_timer = Timer::default();
loop_timer.start(TimerMode::Repeated, Duration::from_secs(1), move || {
let new_digits = current_digits();
if let Some(ui) = ui_loop.upgrade() {
for i in 0..6 {
if new_digits[i] != prev[i] {
// 先设置 flap-value = 旧值(纸页),再设置 value = 新值(背景)
let old_val = prev[i].clone();
let new_val = new_digits[i].clone();
match i {
0 => { ui.set_digit1_flap(old_val.into()); ui.set_digit1_value(new_val.into()); }
// ... 其余 5 位同理
_ => {}
}
flip_digit(&flaps_loop[i]);
}
}
}
prev = new_digits;
});
这里有一个关键设计:
- 先判断当前位数字是否真的变化(
new_digits[i] != prev[i]),只有变化才触发翻页,避免无意义的动画; - 变化时,先把旧值写入
flap-value(纸页),再把新值写入value(背景),这样翻页过程中纸页显示旧数字、背景显示新数字; - 最后调用
flip_digit()驱动纸页从 1.0 缩放到 0.0,视觉上旧数字被“翻走”,露出新数字。
5、Slint 侧的翻页卡片结构
main.slint 中定义了 FlipDigit 组件,核心结构如下:
component FlipDigit {
in property <string> value: "0"; // 新值(背景)
in property <string> flap-value: "0"; // 旧值(纸页)
in property <float> flap-scale: 1.0; // 动画进度
Rectangle {
// 底层:显示新值
Text { text: root.value; /* ... */ }
// 翻页纸页:显示旧值,从顶部往下翻走
Rectangle {
transform-origin: { x: parent.width / 2, y: 0 };
transform-scale-y: root.flap-scale * 100%;
Text { text: root.flap-value; /* ... */ }
}
}
}
翻页效果的视觉实现依赖两个关键属性:
transform-origin:设置为{ x: parent.width / 2, y: 0 },即以卡片顶部中心为旋转/缩放原点,这样纸页会从顶部开始往下翻;transform-scale-y:绑定root.flap-scale * 100%,当 Rust 侧把flap-scale从 1.0 驱动到 0.0 时,纸页的纵向高度逐渐缩小,最终完全消失,露出底层的“新值”。
6、启动时的初始翻页
程序启动 500ms 后,会先让 6 个数字依次做一次翻页动画,作为开场效果:
Timer::single_shot(Duration::from_millis(500), move || {
if ui_init.upgrade().is_none() { return; }
for (i, delay) in [0u64, 80, 160, 240, 320, 400].iter().enumerate() {
let flap = Rc::clone(&flaps_init[i]);
Timer::single_shot(Duration::from_millis(*delay), move || {
flip_digit(&flap);
});
}
});
由于初始化时 flap-value 与 value 相同,所以这次翻页在视觉上表现为“同数字翻页”,营造出时钟启动的仪式感。每个数字之间间隔 80ms,形成从左到右的波浪式翻页效果。
7、小结
| 模块 | 职责 |
|---|---|
current_digits() | 读取系统时间并拆分为 6 个数字字符 |
flip_digit() | 用 22 帧 Timer 逐帧驱动 flap-scale 从 1.0 → 0.0 |
anim_setter! 宏 | 把 Slint setter 封装为可克隆的闭包 |
每秒 Timer | 对比新旧时间,仅在数字变化时触发翻页 |
FlipDigit 组件 | 用 transform-origin + transform-scale-y 实现纸页翻走效果 |
通过 Rust 与 Slint 的分工协作——Rust 负责时间计算与动画驱动,Slint 负责声明式 UI 与视觉呈现——最终实现了流畅的翻页时钟效果。

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_15181569/article/details/164224640




