feat(theme): 切换按钮点击立即换图标并播放进入动画

将 theme.set 从 450ms 延迟改为立即执行——图标在点击瞬间切换,不再
等待圆形 VT 动画结束。原作者担心重渲染打断 VT 实属多余:VT 伪元素
快照在 __startThemeTransition 内已同步拍好,后续真实 DOM 变化不影响。

- 移除 click_gen 防抖机制与 spawn_local 延迟回调(不再需要)。
- SVG 加 key 属性,theme 变化时 Dioxus 重新挂载 SVG,触发 CSS 进入动画。
- input.css 新增 @keyframes theme-icon-enter(缩放 0.5→1 + 旋转 -60°→0°
  + 淡入,0.3s ease-out),仅在 .theme-toggle svg 上生效。
- prefers-reduced-motion 下禁用该动画,遵循项目既有约定。
This commit is contained in:
xfy 2026-07-02 12:00:00 +08:00
parent 9a4e6c3e6e
commit a10003c90d
2 changed files with 83 additions and 60 deletions

View File

@ -634,6 +634,25 @@
background: transparent; background: transparent;
} }
/* ========== 主题切换图标进入动画 ==========
点击切换主题时 theme.set 立即换 SVG配合 RSX key 强制重挂载
该动画为新挂载的图标播放一次缩放+淡入给用户即时反馈
仅作用于 .theme-toggle 内的 svg不影响页面其它 svg */
.theme-toggle svg {
animation: theme-icon-enter 0.3s ease-out;
}
@keyframes theme-icon-enter {
from {
opacity: 0;
transform: scale(0.5) rotate(-60deg);
}
to {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
} }
@keyframes fadeIn { @keyframes fadeIn {
@ -704,6 +723,10 @@
.page-enter { .page-enter {
animation: none; animation: none;
} }
.theme-toggle svg {
animation: none;
}
} }
/* ========== Blur-up 渐进图片加载(已移入 @layer components ========== */ /* ========== Blur-up 渐进图片加载(已移入 @layer components ========== */

View File

@ -284,26 +284,23 @@ pub fn ThemePreload() -> Element {
/// 主题切换按钮组件三态循环Light → Dark → System → Light /// 主题切换按钮组件三态循环Light → Dark → System → Light
/// ///
/// 点击根据当前模式取下一个 `Theme`。仅当**实际生效明暗**ResolvedTheme /// 点击后**立即**切换图标theme.set 不再延迟),让用户即时获得反馈;
/// 因切换而翻转时才触发圆形展开动画;若切换前后明暗不变(如 Light → System /// 图标自身的进入动画(缩放+淡入)由 CSS `.theme-toggle svg` 的 keyframe
/// 且系统当前为浅色),直接更新 Signal无动画无延迟。 /// 驱动,配合 SVG 元素的 `key` 属性——theme 变化时 Dioxus 重新挂载 SVG
/// 进入动画随之重新触发。
///
/// 仅当**实际生效明暗**ResolvedTheme因切换而翻转时才额外触发圆形展开
/// VT 动画(颜色过渡);明暗不变时(如 Light → System 且系统浅色)只换图标。
/// ///
// evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。 // evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
#[component] #[component]
pub fn ThemeToggle() -> Element { pub fn ThemeToggle() -> Element {
// theme 仅在非 wasmserver构建的 theme.set(next) 需要 mutwasm 端该分支 // theme 在 wasm 与 server 两侧都需要 mutonclick 内 theme.set
// 被 cfg 剥离,写操作转移到副本 theme_clone故 wasm 下标注 unused_mut。
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut theme = use_theme();
// generation:每次点击递增。延迟回调检查自己的 gen 是否最新,过期则跳过 set。
// 解决连续点击时多个 spawn_local 堆积导致的状态错乱。
// click_gen 仅在 wasm 分支被 set连续点击防抖server 构建剥离该分支,
// 故非 wasm 端标注 unused_mut。
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
let mut click_gen = use_signal(|| 0u32); let mut theme = use_theme();
// resolved / system_dark 用于判断切换前后实际明暗是否翻转 // resolved / system_dark 用于判断切换前后实际明暗是否翻转(决定是否触发 VT
// resolved 仅在 wasm 分支读取system_dark 同理;非 wasm 端用 _ 引用避免警告。 // 仅在 wasm 分支读取;非 wasm 端用占位值避免 unused 警告。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let resolved = use_resolved_theme(); let resolved = use_resolved_theme();
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@ -324,7 +321,7 @@ pub fn ThemeToggle() -> Element {
let prev_resolved = resolved(); let prev_resolved = resolved();
let new_resolved = next.resolve(system_dark()); let new_resolved = next.resolve(system_dark());
if prev_resolved != new_resolved { if prev_resolved != new_resolved {
// 实际明暗翻转 → 触发圆形展开动画。 // 实际明暗翻转 → 触发圆形展开 VT 动画(颜色过渡)
// JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。 // JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
let coords = evt.client_coordinates(); let coords = evt.client_coordinates();
let x = coords.x; let x = coords.x;
@ -332,25 +329,16 @@ pub fn ThemeToggle() -> Element {
let _ = js_sys::eval( let _ = js_sys::eval(
&format!( &format!(
"if (window.__startThemeTransition) \ "if (window.__startThemeTransition) \
window.__startThemeTransition({x}, {y});", // theme.set 推迟到动画结束:其触发的 Dioxus 微任务重渲染会打断 VT。 window.__startThemeTransition({x}, {y});",
x = x, x = x,
y = y, y = y,
), ),
); );
let gen = click_gen() + 1;
click_gen.set(gen);
let mut theme_clone = theme;
let gen_clone = click_gen;
wasm_bindgen_futures::spawn_local(async move {
crate::utils::time::sleep_ms(450).await;
if gen_clone() == gen {
theme_clone.set(next);
}
});
} else {
// 切换前后明暗不变(如 Light → System 且系统浅色):直接更新,无动画。
theme.set(next);
} }
// 立即切换图标theme.set 触发重渲染换 SVG配合 key 触发 CSS 进入动画。
// VT 的伪元素快照在 __startThemeTransition 内已同步拍好,不受后续真实
// DOM 变化影响,故无需推迟 theme.set。
theme.set(next);
return; return;
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@ -359,37 +347,49 @@ pub fn ThemeToggle() -> Element {
} }
}, },
// 图标按当前主题意图(而非 resolved选择用户能看出自己在哪种模式。 // 图标按当前主题意图(而非 resolved选择用户能看出自己在哪种模式。
match theme() { // key 强制 theme 变化时 Dioxus 重新挂载 SVG从而重新触发 CSS 进入动画。
Theme::Dark => rsx! { // Dioxus 要求 key 为格式化字符串,故用 {icon_key} 占位。)
svg { {
xmlns: "http://www.w3.org/2000/svg", let icon_key: &str = match theme() {
height: "24px", Theme::Dark => "dark",
view_box: "0 -960 960 960", Theme::Light => "light",
width: "24px", Theme::System => "system",
fill: "currentColor", };
path { d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z" } match theme() {
} Theme::Dark => rsx! {
}, svg {
Theme::Light => rsx! { key: "icon-{icon_key}",
svg { xmlns: "http://www.w3.org/2000/svg",
xmlns: "http://www.w3.org/2000/svg", height: "24px",
height: "24px", view_box: "0 -960 960 960",
view_box: "0 -960 960 960", width: "24px",
width: "24px", fill: "currentColor",
fill: "currentColor", path { d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z" }
path { d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z" } }
} },
}, Theme::Light => rsx! {
Theme::System => rsx! { svg {
svg { key: "icon-{icon_key}",
xmlns: "http://www.w3.org/2000/svg", xmlns: "http://www.w3.org/2000/svg",
height: "24px", height: "24px",
view_box: "0 -960 960 960", view_box: "0 -960 960 960",
width: "24px", width: "24px",
fill: "currentColor", fill: "currentColor",
path { d: "M40-120v-80h880v80H40Zm120-120q-33 0-56.5-23.5T80-320v-440q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v440q0 33-23.5 56.5T800-240H160Zm0-80h640v-440H160v440Zm0 0v-440 440Z" } path { d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z" }
} }
}, },
Theme::System => rsx! {
svg {
key: "icon-{icon_key}",
xmlns: "http://www.w3.org/2000/svg",
height: "24px",
view_box: "0 -960 960 960",
width: "24px",
fill: "currentColor",
path { d: "M40-120v-80h880v80H40Zm120-120q-33 0-56.5-23.5T80-320v-440q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v440q0 33-23.5 56.5T800-240H160Zm0-80h640v-440H160v440Zm0 0v-440 440Z" }
}
},
}
} }
} }
} }