第十六章:动画与过渡效果

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第十六章:动画与过渡效果

1. Dioxus 动画方案概览

| 方案 | 实现方式 | 适用场景 | 性能 | |------|---------|---------|------| | CSS Transition | transition-* 类 | 悬停、显隐、简单状态变化 | 极高(GPU 加速) | | CSS Animation | @keyframes | 加载提示、强调效果、循环动画 | 极高 | | 条件类名切换 | Signal + 类名拼接 | 进入/离开、展开/收起 | 高 | | JS 动画 | requestAnimationFrame | 复杂路径、物理模拟 | 中 | | Web API | web-sys 绑定 | Canvas、WebGL | 低(按需) |

2. CSS Transition 基础

2.1 属性过渡

button {
    class: "transition-all duration-200 ease-in-out",
    style: "
        background: {if hovered() { \"var(--primary)\" } else { \"var(--card)\" }};
        transform: {if hovered() { \"scale(1.02)\" } else { \"scale(1)\" }};
        box-shadow: {if hovered() { \"0 4px 12px rgba(0,0,0,0.1)\" } else { \"0 1px 3px rgba(0,0,0,0.05)\" }};
    ",
}

// 使用 Tailwind 工具类更简洁
button {
    class: "
        px-4 py-2 rounded-lg
        transition-all duration-200
        hover:bg-blue-500 hover:text-white hover:shadow-lg
        active:scale-95
    ",
}

2.2 控制过渡触发时机

#[component]
fn ExpandableCard(title: String, children: Element) -> Element {
    let expanded = use_signal(|| false);

    rsx! {
        div { class: "border rounded-lg overflow-hidden",
            // 点击标题展开/收起
            button {
                class: "w-full px-4 py-3 flex items-center justify-between
                        transition-colors duration-150 hover:bg-gray-50",
                onclick: move |_| expanded.toggle(),
                span { "{title}" }
                span {
                    class: "transition-transform duration-200",
                    style: "transform: rotate({if expanded() { \"180deg\" } else { \"0deg\" }});",
                    "▼"
                }
            }
            // 内容区域:高度过渡
            div {
                class: "transition-all duration-300 ease-in-out overflow-hidden",
                style: "
                    max-height: {if expanded() { \"500px\" } else { \"0\" }};
                    opacity: {if expanded() { \"1\" } else { \"0\" }};
                ",
                div { class: "px-4 py-3", {children} }
            }
        }
    }
}

2.3 多步骤过渡

#[component]
fn MultiStepTransition() -> Element {
    let state = use_signal(|| 0u8);

    // 使用 use_memo 计算每个阶段的样式
    let card_style = use_memo(move || {
        match state() {
            0 => "opacity:0; transform: translateY(20px) scale(0.95);".to_string(),
            1 => "opacity:1; transform: translateY(0) scale(1);".to_string(),
            2 => "opacity:0; transform: translateY(-20px) scale(1.05);".to_string(),
            _ => "opacity:0;".to_string(),
        }
    });

    rsx! {
        div {
            div {
                class: "transition-all duration-500 ease-out",
                style: "{card_style}",
                "内容卡片"
            }
            button {
                onclick: move |_| {
                    if state() < 2 { state += 1; } else { state.set(0); }
                },
                "下一步"
            }
        }
    }
}

3. CSS Animation 与 @keyframes

3.1 加载骨架屏

#[component]
fn SkeletonCard() -> Element {
    rsx! {
        div { class: "border rounded-lg p-4 space-y-3",
            div { class: "h-4 bg-gray-200 rounded animate-pulse w-3/4" }
            div { class: "h-4 bg-gray-200 rounded animate-pulse" }
            div { class: "h-4 bg-gray-200 rounded animate-pulse w-5/6" }
            div { class: "h-20 bg-gray-200 rounded animate-pulse" }
        }
    }
}

3.2 自定义动画

/* main.css */
@keyframes fadeInUp {
    from {
        opacity: 0;
        transform: translateY(30px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

@keyframes fadeInScale {
    from {
        opacity: 0;
        transform: scale(0.9);
    }
    to {
        opacity: 1;
        transform: scale(1);
    }
}

@keyframes slideInRight {
    from { transform: translateX(100%); }
    to   { transform: translateX(0); }
}

@keyframes pulse-dot {
    0%, 80%, 100% { transform: scale(0); opacity: 0.5; }
    40% { transform: scale(1); opacity: 1; }
}

@keyframes shimmer {
    0% { background-position: -200% 0; }
    100% { background-position: 200% 0; }
}

3.3 使用自定义动画

// 列表项逐个入场
#[component]
fn AnimatedList(items: Vec<ListItem>) -> Element {
    rsx! {
        for (i, item) in items.iter().enumerate() {
            div {
                key: "{item.id}",
                class: "transition-all duration-300",
                style: "
                    animation: fadeInUp 0.4s ease-out {i * 80}ms both;
                ",
                // 每个项目延迟 80ms 依次出现
                ListItemCard { item: item.clone() }
            }
        }
    }
}

// 骨架屏闪烁效果
div {
    class: "rounded-lg",
    style: "
        background: linear-gradient(90deg,
            var(--hover) 25%,
            var(--card) 50%,
            var(--hover) 75%
        );
        background-size: 200% 100%;
        animation: shimmer 1.5s infinite;
        height: 200px;
    ",
}

4. 条件渲染动画

key id="4-1-使用-触发进入离开动画">4.1 使用 触发进入/离开动画
// 当 key 变化时,Dioxus 会移除旧元素并创建新元素
// 结合 CSS animation 实现切换效果
div {
    key: "{current_view}",
    class: "animate-fadeIn",
    match current_view() {
        "list" => rsx! { ArticleList {} },
        "detail" => rsx! { ArticleDetail {} },
        _ => rsx! { div {} },
    }
}

4.2 if 条件动画

#[component]
fn Toast(message: Signal<Option<String>>) -> Element {
    let visible = message().is_some();

    rsx! {
        div {
            class: "
                fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg
                transition-all duration-300
                text-sm text-white
            ",
            style: "
                background: var(--primary);
                opacity: {if visible { \"1\" } else { \"0\" }};
                transform: translateY({if visible { \"0\" } else { \"-20px\" }});
                pointer-events: {if visible { \"auto\" } else { \"none\" }};
            ",
            if let Some(msg) = &*message.read() {
                span { "{msg}" }
            }
        }
    }
}

5. 交互动画

5.1 按钮涟漪效果

#[component]
fn RippleButton(children: Element) -> Element {
    let mut ripples = use_signal(|| Vec::<(f64, f64, u64)>::new());

    rsx! {
        button {
            class: "relative overflow-hidden px-6 py-2 rounded-lg
                    bg-blue-500 text-white font-medium",
            onclick: move |e| {
                let rect = e.get_client_rect();
                let x = e.client_x() - rect.left;
                let y = e.client_y() - rect.top;
                let id = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap().as_millis() as u64;
                ripples.write().push((x, y, id));

                // 动画结束后自动移除
                let mut r = ripples;
                spawn(async move {
                    tokio::time::sleep(std::time::Duration::from_millis(600)).await;
                    r.write().retain(|&(_, _, i)| i != id);
                });
            },
            {children}
            for (x, y, _) in ripples.read().iter() {
                div {
                    class: "absolute w-4 h-4 rounded-full bg-white/30 animate-ripple",
                    style: "left: {x - 8}px; top: {y - 8}px;",
                }
            }
        }
    }
}

CSS:

@keyframes ripple {
    from { transform: scale(0); opacity: 0.5; }
    to   { transform: scale(15); opacity: 0; }
}
.animate-ripple { animation: ripple 0.6s ease-out both; }

5.2 滚动触发动画

#[component]
fn ScrollReveal(children: Element) -> Element {
    let visible = use_signal(|| false);
    let element_ref = use_node_ref();

    use_effect(move || {
        // Intersection Observer 检测元素进入视口
        let el = element_ref.get();
        if let Some(element) = el {
            // 通过 web-sys 创建 IntersectionObserver
            // 元素进入视口时设置 visible = true
        }
    });

    rsx! {
        div {
            class: "transition-all duration-700",
            style: "
                opacity: {if visible() { \"1\" } else { \"0\" }};
                transform: translateY({if visible() { \"0\" } else { \"40px\" }});
            ",
            onmounted: move |_| visible.set(true), // 简化:直接挂载时显示
            {children}
        }
    }
}

6. 路由过渡动画

use dioxus::prelude::*;

#[component]
fn AnimatedLayout() -> Element {
    // 通过 key 触发组件级别的过渡
    let route_key = use_context::<Signal<String>>();

    rsx! {
        div {
            class: "transition-all duration-300",
            key: "{route_key}",
            style: "
                animation: fadeInUp 0.3s ease-out;
            ",
            Outlet::<Route> {}
        }
    }
}

7. 列表项拖拽动画

#[component]
fn DraggableList() -> Element {
    let items = use_signal(|| vec!["A", "B", "C", "D", "E"]);

    // 简单实现:点击上下移动
    let move_up = move |index: usize| {
        if index == 0 { return; }
        items.write().swap(index, index - 1);
    };

    let move_down = move |index: usize| {
        if index >= items.read().len() - 1 { return; }
        items.write().swap(index, index + 1);
    };

    rsx! {
        div { class: "space-y-2",
            for (i, item) in items.iter().enumerate() {
                div {
                    key: "{item}",
                    class: "flex items-center gap-2 px-4 py-3 rounded-lg
                            transition-all duration-300 border",
                    style: "background: var(--card); border-color: var(--border);",
                    span { class: "flex-1", "{item}" }
                    button { onclick: move |_| move_up(i), "↑" }
                    button { onclick: move |_| move_down(i), "↓" }
                }
            }
        }
    }
}

8. 性能优化建议

| 优化点 | 说明 | |--------|------| | 优先使用 transformopacity | 这两个属性由 GPU 合成,不触发重排 | | 避免频繁读写 layout 属性 | width/height/top/left 会触发重排 | | 使用 will-change 提示浏览器 | will-change: transform, opacity; | | 动画元素提升为独立图层 | transform: translateZ(0);will-change: transform; | | requestAnimationFrame 控制 JS 动画 | 与浏览器刷新率同步,避免掉帧 | | 动画持续时间控制在 200-500ms | 太短看不清,太长显拖沓 |

// 高性能动画的 CSS 写法
div {
    class: "transition-[transform,opacity] duration-300",
    style: "
        will-change: transform, opacity;
        transform: translateZ(0); /* 启用 GPU 加速 */
    ",
}

9. 小结

  • CSS Transition 适合简单状态变化,CSS Animation 适合复杂/循环动画
  • 条件渲染时用 key 触发元素的创建/销毁动画
  • use_memo 预计算动态样式,避免在渲染中重复计算
  • 动画优先使用 transformopacity,性能最佳
  • 骨架屏和微交互动画提升用户体验
  • 列表项错开延迟依次入场,效果比整体出现好得多
dioxusanimationtransitioncssux