跳到主要内容

实时系统中的动画与过渡设计

目录


动画的作用与原则

动画的四大作用

在实时系统中,动画不仅仅是装饰,而是重要的功能性元素:

1. 引导注意力 ─────► 新数据进入、状态变化
2. 建立空间关系 ───► 元素从哪来、到哪去
3. 提供反馈 ───────► 操作成功/失败、加载中
4. 增强理解 ───────► 数据变化趋势、因果关系

迪士尼动画12原则的应用

经典动画原则在UI动画中的应用:

原则UI应用实时系统示例
挤压与拉伸按钮点击反馈数据点脉冲效果
预备动作抽屉打开前的轻微回缩图表缩放前的预览
缓入缓出所有过渡动画数值平滑变化
跟随动作菜单项依次弹出数据流瀑布效果
次要动作微妙的阴影变化背景指示器
时间节奏快速反馈vs慢速过渡紧急告警vs常规更新

缓动函数

基础缓动类型

缓动函数决定了动画的"感觉"。

Ease-out(缓出) - 开始快,结束慢

用途:进入动画、吸引注意
场景:新消息通知弹出、数据点出现

Ease-in(缓入) - 开始慢,结束快

用途:退出动画、消失效果
场景:关闭弹窗、删除项目

Ease-in-out(缓入缓出) - 两端慢,中间快

用途:位置变化、状态转换
场景:排序、拖拽、页面切换

Linear(线性) - 匀速

用途:持续的加载指示器
场景:进度条、旋转加载动画

实现示例

// 缓动函数库
const Easing = {
// 标准缓动
easeOutQuad: (t: number) => t * (2 - t),
easeInOutQuad: (t: number) => t < 0.5
? 2 * t * t
: -1 + (4 - 2 * t) * t,

// 弹性效果(适合实时通知)
easeOutElastic: (t: number) => {
const c4 = (2 * Math.PI) / 3;
return t === 0 ? 0
: t === 1 ? 1
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},

// 弹跳效果(适合错误提示)
easeOutBounce: (t: number) => {
const n1 = 7.5625;
const d1 = 2.75;

if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
}
};

// 动画引擎
class Animator {
animate(
from: number,
to: number,
duration: number,
easing: (t: number) => number,
onUpdate: (value: number) => void
) {
const startTime = Date.now();

const tick = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easing(progress);
const currentValue = from + (to - from) * easedProgress;

onUpdate(currentValue);

if (progress < 1) {
requestAnimationFrame(tick);
}
};

requestAnimationFrame(tick);
}
}

// 使用示例
const animator = new Animator();

animator.animate(
0, // from
100, // to
500, // duration (ms)
Easing.easeOutElastic,
(value) => {
element.style.transform = `translateY(${value}px)`;
}
);

CSS动画实现

/* 定义缓动变量 */
:root {
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* 新消息通知 */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

.notification {
animation: slideInRight 0.3s var(--ease-out-quad);
}

/* 数据点脉冲 */
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}

.data-point.new {
animation: pulse 0.6s var(--ease-out-quad);
}

/* 加载骨架屏 */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}

.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite linear;
}

数据可视化动画

1. 数值平滑过渡

实时数据经常突变,需要平滑过渡来降低认知负担。

class SmoothCounter {
private currentValue: number = 0;
private targetValue: number = 0;
private animationId: number | null = null;

setValue(newValue: number, duration: number = 500) {
this.targetValue = newValue;

if (this.animationId) {
cancelAnimationFrame(this.animationId);
}

const startValue = this.currentValue;
const startTime = Date.now();

const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = Easing.easeOutQuad(progress);

this.currentValue = startValue + (newValue - startValue) * eased;
this.render();

if (progress < 1) {
this.animationId = requestAnimationFrame(animate);
}
};

animate();
}

render() {
// 智能格式化
const formatted = this.currentValue > 1000
? (this.currentValue / 1000).toFixed(1) + 'k'
: Math.round(this.currentValue).toString();

this.element.textContent = formatted;
}
}

React实现:

const AnimatedCounter = ({ value, duration = 500 }) => {
const [displayValue, setDisplayValue] = useState(value);

useEffect(() => {
const startValue = displayValue;
const startTime = Date.now();

const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = progress * (2 - progress); // easeOutQuad

const current = startValue + (value - startValue) * eased;
setDisplayValue(current);

if (progress < 1) {
requestAnimationFrame(animate);
}
};

requestAnimationFrame(animate);
}, [value]);

return <span>{Math.round(displayValue).toLocaleString()}</span>;
};

2. 图表动画

折线图平滑绘制:

class AnimatedLineChart {
private path: SVGPathElement;
private fullPath: string;

animateDraw(points: Point[], duration: number = 1000) {
// 生成完整路径
this.fullPath = this.generatePath(points);
this.path.setAttribute('d', this.fullPath);

// 获取路径长度
const length = this.path.getTotalLength();

// 设置stroke-dasharray使路径初始不可见
this.path.style.strokeDasharray = `${length}`;
this.path.style.strokeDashoffset = `${length}`;

// 动画绘制
const startTime = Date.now();

const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = Easing.easeInOutQuad(progress);

const offset = length * (1 - eased);
this.path.style.strokeDashoffset = `${offset}`;

if (progress < 1) {
requestAnimationFrame(animate);
}
};

requestAnimationFrame(animate);
}

// 新数据点进入动画
addPoint(newPoint: Point) {
const oldPoints = this.points;
this.points.push(newPoint);

// 创建临时点用于动画
const tempPoint = { ...newPoint };

const animate = () => {
// 从前一个点位置开始
const prevPoint = oldPoints[oldPoints.length - 1];
tempPoint.x = prevPoint.x + (newPoint.x - prevPoint.x) * progress;
tempPoint.y = prevPoint.y + (newPoint.y - prevPoint.y) * progress;

this.redraw([...oldPoints, tempPoint]);
};

// ... 动画逻辑
}
}

柱状图增长动画:

const AnimatedBarChart = ({ data }) => {
const [heights, setHeights] = useState(data.map(() => 0));

useEffect(() => {
const targetHeights = data.map(d => d.value);
const startTime = Date.now();

const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / 800, 1);
const eased = Easing.easeOutQuad(progress);

setHeights(targetHeights.map(h => h * eased));

if (progress < 1) {
requestAnimationFrame(animate);
}
};

requestAnimationFrame(animate);
}, [data]);

return (
<svg width={400} height={300}>
{data.map((d, i) => (
<rect
key={d.label}
x={i * 60}
y={300 - heights[i]}
width={50}
height={heights[i]}
fill="#3b82f6"
/>
))}
</svg>
);
};

3. 数据流动画

模拟数据在系统中的流动。

const DataFlowVisualization = () => {
const [particles, setParticles] = useState([]);

// 持续产生新粒子
useEffect(() => {
const interval = setInterval(() => {
const newParticle = {
id: Date.now(),
x: 0,
y: Math.random() * 200,
vx: 2 + Math.random() * 3
};

setParticles(prev => [...prev, newParticle]);
}, 500);

return () => clearInterval(interval);
}, []);

// 动画循环
useEffect(() => {
const animate = () => {
setParticles(prev =>
prev
.map(p => ({ ...p, x: p.x + p.vx }))
.filter(p => p.x < 800) // 移除屏幕外的粒子
);

requestAnimationFrame(animate);
};

const rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, []);

return (
<svg width={800} height={200}>
<defs>
<radialGradient id="particleGradient">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="1" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
</radialGradient>
</defs>

{particles.map(p => (
<circle
key={p.id}
cx={p.x}
cy={p.y}
r={4}
fill="url(#particleGradient)"
>
{/* 拖尾效果 */}
<animate
attributeName="opacity"
from="1"
to="0.3"
dur="1s"
repeatCount="indefinite"
/>
</circle>
))}
</svg>
);
};

状态转换动画

1. 列表项过渡

新增、删除、重排时的动画。

import { TransitionGroup, CSSTransition } from 'react-transition-group';

const AnimatedList = ({ items }) => {
return (
<TransitionGroup>
{items.map(item => (
<CSSTransition
key={item.id}
timeout={300}
classNames="item"
>
<div className="list-item">
{item.content}
</div>
</CSSTransition>
))}
</TransitionGroup>
);
};

// CSS
.item-enter {
opacity: 0;
transform: translateX(-100%);
}

.item-enter-active {
opacity: 1;
transform: translateX(0);
transition: all 300ms ease-out;
}

.item-exit {
opacity: 1;
transform: scale(1);
}

.item-exit-active {
opacity: 0;
transform: scale(0.8);
transition: all 200ms ease-in;
}

高级实现 - FLIP技术:

// First, Last, Invert, Play
class FLIPAnimation {
animate(element: HTMLElement, callback: () => void) {
// First - 记录初始位置
const first = element.getBoundingClientRect();

// Last - 执行DOM变化
callback();

// 记录最终位置
const last = element.getBoundingClientRect();

// Invert - 计算差值并应用反向变换
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;
const deltaW = first.width / last.width;
const deltaH = first.height / last.height;

element.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(${deltaW}, ${deltaH})
`;
element.style.transition = 'none';

// Play - 强制重排后动画到最终位置
requestAnimationFrame(() => {
element.style.transform = '';
element.style.transition = 'transform 300ms ease-out';
});
}
}

// 使用示例
const flipper = new FLIPAnimation();

function reorderItems() {
items.forEach(item => {
flipper.animate(item.element, () => {
// 改变DOM顺序
container.appendChild(item.element);
});
});
}

2. 加载状态过渡

const LoadingTransition = ({ isLoading, data }) => {
return (
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Skeleton variant="rectangular" height={200} />
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<DataView data={data} />
</motion.div>
)}
</AnimatePresence>
);
};

3. 状态指示器

// 脉动的"实时"指示器
const LiveIndicator = () => {
return (
<Box display="flex" alignItems="center" gap={1}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: 'success.main',
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.5 }
}
}}
/>
<Typography variant="caption" color="text.secondary">
Live
</Typography>
</Box>
);
};

// 连接状态转换
const ConnectionStatus = ({ status }) => {
const configs = {
connected: { color: 'success', icon: '●', text: 'Connected' },
connecting: { color: 'warning', icon: '◐', text: 'Connecting...' },
disconnected: { color: 'error', icon: '○', text: 'Disconnected' }
};

const config = configs[status];

return (
<Chip
icon={
<Box
component="span"
sx={{
animation: status === 'connecting'
? 'rotate 1s linear infinite'
: 'none',
'@keyframes rotate': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' }
}
}}
>
{config.icon}
</Box>
}
label={config.text}
color={config.color}
size="small"
/>
);
};

性能优化

1. 使用CSS Transform

优先使用transformopacity,它们不会触发重排。

// ❌ 低效 - 触发重排
element.style.left = '100px';
element.style.top = '50px';

// ✅ 高效 - 使用GPU加速的transform
element.style.transform = 'translate(100px, 50px)';

// ✅ 开启硬件加速
element.style.willChange = 'transform';

性能对比:

操作类型           | 重排 | 重绘 | 复合 |
-------------------|------|------|------|
left/top/width | ✓ | ✓ | ✓ |
transform | | | ✓ |
opacity | | | ✓ |

2. 批量DOM更新

class BatchUpdater {
private updates: Array<() => void> = [];
private rafId: number | null = null;

schedule(update: () => void) {
this.updates.push(update);

if (!this.rafId) {
this.rafId = requestAnimationFrame(() => {
// 读取阶段
const reads = this.updates.filter(u => u.type === 'read');
reads.forEach(r => r());

// 写入阶段
const writes = this.updates.filter(u => u.type === 'write');
writes.forEach(w => w());

this.updates = [];
this.rafId = null;
});
}
}
}

3. 虚拟化+动画

import { VariableSizeList } from 'react-window';
import { motion } from 'framer-motion';

const VirtualizedAnimatedList = ({ items }) => {
const Row = ({ index, style }) => {
const item = items[index];

return (
<motion.div
style={style}
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ delay: index * 0.05 }}
>
<ListItem data={item} />
</motion.div>
);
};

return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={() => 60}
width="100%"
>
{Row}
</VariableSizeList>
);
};

4. 降级策略

根据设备性能自动调整动画复杂度。

class AnimationManager {
private performanceTier: 'low' | 'medium' | 'high';

constructor() {
this.performanceTier = this.detectPerformance();
}

private detectPerformance() {
// 检测设备性能
const memory = (navigator as any).deviceMemory || 4;
const cores = navigator.hardwareConcurrency || 2;

if (memory >= 8 && cores >= 4) return 'high';
if (memory >= 4 && cores >= 2) return 'medium';
return 'low';
}

getAnimationConfig() {
const configs = {
low: {
duration: 150,
easing: 'linear',
enableParticles: false,
enableBlur: false
},
medium: {
duration: 250,
easing: 'ease-out',
enableParticles: true,
enableBlur: false
},
high: {
duration: 400,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
enableParticles: true,
enableBlur: true
}
};

return configs[this.performanceTier];
}

// 检测是否启用了"减少动画"偏好
shouldReduceMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
}

实战案例

案例1: 实时股票价格动画

const StockPrice = ({ symbol, price, change }) => {
const [displayPrice, setDisplayPrice] = useState(price);
const [flash, setFlash] = useState<'up' | 'down' | null>(null);

useEffect(() => {
// 价格变化时闪烁
if (price > displayPrice) {
setFlash('up');
} else if (price < displayPrice) {
setFlash('down');
}

// 平滑过渡到新价格
const animator = new Animator();
animator.animate(
displayPrice,
price,
300,
Easing.easeOutQuad,
(val) => setDisplayPrice(val)
);

// 闪烁效果持续300ms
const timer = setTimeout(() => setFlash(null), 300);
return () => clearTimeout(timer);
}, [price]);

return (
<Box
sx={{
padding: 2,
backgroundColor: flash === 'up'
? 'rgba(34, 197, 94, 0.1)'
: flash === 'down'
? 'rgba(239, 68, 68, 0.1)'
: 'transparent',
transition: 'background-color 0.3s ease-out'
}}
>
<Typography variant="h6" fontWeight="bold">
{symbol}
</Typography>
<Typography
variant="h4"
color={change >= 0 ? 'success.main' : 'error.main'}
>
${displayPrice.toFixed(2)}
</Typography>
<Typography
variant="body2"
color={change >= 0 ? 'success.main' : 'error.main'}
>
{change >= 0 ? '↑' : '↓'} {Math.abs(change).toFixed(2)}%
</Typography>
</Box>
);
};

案例2: 活动时间线

const ActivityTimeline = () => {
const [activities, setActivities] = useState([]);

useRealtimeActivities((newActivity) => {
setActivities(prev => [newActivity, ...prev].slice(0, 50));
});

return (
<Box>
<TransitionGroup>
{activities.map((activity, index) => (
<CSSTransition
key={activity.id}
timeout={500}
classNames="activity"
>
<motion.div
initial={{ opacity: 0, height: 0, y: -20 }}
animate={{ opacity: 1, height: 'auto', y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{
duration: 0.3,
delay: index * 0.05
}}
>
<ActivityCard activity={activity} />
</motion.div>
</CSSTransition>
))}
</TransitionGroup>
</Box>
);
};

案例3: 实时地图标记

const RealtimeMapMarkers = ({ markers }) => {
return (
<MapContainer>
{markers.map(marker => (
<motion.div
key={marker.id}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{
type: 'spring',
stiffness: 260,
damping: 20
}}
style={{
position: 'absolute',
left: marker.x,
top: marker.y
}}
>
<Marker data={marker} />

{/* 涟漪效果 */}
{marker.isNew && (
<motion.div
style={{
position: 'absolute',
inset: -10,
borderRadius: '50%',
border: '2px solid #3b82f6'
}}
initial={{ scale: 1, opacity: 1 }}
animate={{ scale: 2, opacity: 0 }}
transition={{ duration: 1.5, repeat: 3 }}
/>
)}
</motion.div>
))}
</MapContainer>
);
};

动画时长指南

根据动画类型选择合适的时长:

动画类型                    | 时长    | 缓动
---------------------------|---------|------------------
微交互(hover, focus) | 100ms | Linear
简单过渡(fade, slide) | 200-300ms | Ease-out
复杂过渡(morph, rotate) | 300-500ms | Ease-in-out
页面转场 | 400-600ms | Ease-in-out
装饰性动画 | 800ms+ | Elastic/Bounce

原则:

  • 快速操作 → 短动画(避免用户等待)
  • 重要变化 → 较长动画(确保用户注意到)
  • 频繁动画 → 短时长(避免疲劳)

可访问性考虑

尊重用户偏好

const useReducedMotion = () => {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = () => setPrefersReducedMotion(mediaQuery.matches);

mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);

return prefersReducedMotion;
};

// 使用
const MyComponent = () => {
const prefersReducedMotion = useReducedMotion();

const variants = {
hidden: { opacity: 0, y: prefersReducedMotion ? 0 : 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: prefersReducedMotion ? 0.01 : 0.3
}
}
};

return (
<motion.div
initial="hidden"
animate="visible"
variants={variants}
>
Content
</motion.div>
);
};

最佳实践总结

  1. 有目的地使用动画 - 每个动画都应有明确的功能作用
  2. 保持一致性 - 同类操作使用相同的动画模式
  3. 优先性能 - 使用transform和opacity,避免触发重排
  4. 尊重用户偏好 - 支持prefers-reduced-motion
  5. 提供视觉反馈 - 所有交互都应有即时反馈
  6. 适度使用 - 过多动画会分散注意力
  7. 测试真实设备 - 在低端设备上验证性能

通过合理使用动画,实时系统可以变得更加直观、易用和令人愉悦。