引言
在我个人项目「昆仑虚」 中有如下动效:

是的, 动效其实就是想抄 Mac Dock
栏。但是整体动画效果还是差了点意思, 目前的做法比较简单:
- 监听鼠标事件, 判断下目前鼠标悬停在哪个菜单项上
- 然后就是将前后几个菜单设置不同的一个缩放比例
而实际动效应该是下面这样的, 整个动画应该是流畅的, 并且即便鼠标是在同一个菜单上移动, 同样也是会对整个动画造成影响

下面就开始一步步实现该效果....
相关资料:
一、整体布局
开始前, 我们先把基本的布局撸出来
如下代码, 先创建一个容器, 先整个好看的背景
js
import React from 'react';
import scss from './index.module.scss';
export default () => (
<div className={scss.main} />
);
scss
.main {
width: 100%;
height: 100%;
background: center url("./bg.jpg");
background-size: cover;
}
下面我们把整个 Dock
栏先整出来:
- 新增一个
div
节点 - 毛玻璃实现: 样式中通过
backdrop-filter
属性将Dock
背景毛玻璃化 - 定位: 通过
position + bottom
将Dock
栏置底, 通过position + bottom + transform: translateX(-50%)
将Dock
进行水平居中
diff
// 组件代码
export default () => (
<div className={scss.main}>
+ <div className={scss.wrapper}>
+ </div>
</div>
);
diff
// 样式代码
.main {
...
+ position: relative;
...
}
+ $size: 60px;
+ .wrapper {
+ border-radius: 8px;
+ backdrop-filter: blur(5px);
+ background-color: rgba($color: #fff, $alpha: 10%);
+
+ width: 600px;
+ height: $size;
+ padding: 10px;
+
+ left: 50%;
+ bottom: 10px;
+ position: absolute;
+ transform: translateX(-50%);
+ }
效果大概如下:

最后我们把所以菜单加上:
- 造一批数据, 将其作为菜单进行渲染出来
- 使用
flex
布局将菜单铺平, 这里还设置了align-items: flex-end;
让所有菜单置底
diff
...
+ const data = [
+ { color: '#ff4d4f' },
+ { color: '#ff7a45' },
+ { color: '#ffa940' },
+ { color: '#ffc53d' },
+ { color: '#ffec3d' },
+ { color: '#bae637' },
+ { color: '#73d13d' },
+ { color: '#36cfc9' },
+ { color: '#4096ff' },
+ { color: '#597ef7' },
+ { color: '#9254de' },
+ { color: '#f759ab' },
+ ];
+
+ const Item = ({ color }) => (
+ <div
+ className={scss.item}
+ style={{ backgroundColor: color }}
+ />
+ );
export default () => (
<div className={scss.main}>
<div className={scss.wrapper}>
+ {data.map((v) => (<Item color={v.color} />))}
</div>
</div>
);
diff
...
.wrapper {
...
- width: 600px;
+ display: inline-flex;
+ align-items: flex-end;
...
}
+ .item {
+ border-radius: 8px;
+
+ width: $size;
+ height: $size;
+ margin: 0 5px;
+ }
到此最终效果如下:

二、波形动画实现
2.1 波形函数可以怎么实现?
如下图所示, 在 Mac
中 Dock
栏, 当我们鼠标放置在某个菜单上时, 菜单相邻的左右两边若干个菜单, 无论大小、间距都是呈现一个波形

而 JS
中, 我们可以使用 Math.sin
函数来创建 正弦波
, 如下所示, 是 Math.sin
函数对应参数(角度)和返回值的一个关系图

对于 正弦波
当角度限制在 0 ~ π
就可以获取到我们想要的一个波形图, 而对于结果(Y
坐标的值)则在 0 ~ 1
之间:

只是呢, 正弦波
波幅还是偏高, 这里我们针对 Math.sin
结果, 乘上一个系数, 就可以起到控制波幅的作用
js
// 正弦波
function curve(amplitude = 1, value) {
return amplitude * Math.sin(value * Math.PI);
}
2.2 动画实现
整个动画实现还是比较简单的:
- 监听鼠标事件, 获取当前鼠标相对于视口的横向位置, 该点即整个波形的中心点
- 通过波形的中心点, 即可计算出整个波形的开始位置和结束位置(假定波形的范围为
600
)- 最后我们就可以通过每个菜单的位置, 以及波形的开始位置和结束位置, 计算出每个坐落在波形范围内菜单的
放大比例
- 得到每个菜单的
放大比例
后, 通过CSS
变量的方式, 将每个菜单放大比例值应用到样式上, 包括控制菜单的整体大小、间距
- 首先先把相关事件绑定上: 鼠标移动时获取鼠标位置、并传给每个菜单组件
diff
+ const Item = ({ color, clientX }) => (
<div
className={scss.item}
style={{ backgroundColor: color }}
/>
);
export default () => {
+ const [clientX, setClientX] = useState(null);
+ const handleMouseEnter = useCallback((e) => {
+ setClientX(e.clientX);
+ }, []);
+ const handleMouseMove = useCallback((e) => {
+ setClientX(e.clientX);
+ }, []);
+ const handleMouseLeave = useCallback(() => {
+ setClientX(null);
+ }, []);
return (
<div className={scss.main}>
<div
+ className={scss.wrapper}
+ onMouseEnter={handleMouseEnter}
+ onMouseMove={handleMouseMove}
+ onMouseLeave={handleMouseLeave}>
{data.map((v) => (
<Item
color={v.color}
+ clientX={clientX}
/>
))}
</div>
</div>
);
};
- 创建波形函数: 根据当前波形中心位置(当前鼠标位置)以及当前菜单位置, 计算出该菜单放大的比例
js
const curveRange = 600; // 波形范围
const minScale = 1; // 最小的缩放比例
const maxScale = 1.8; // 最大的缩放比例
/**
* 比例波形
*
* @param {*} params 参数
* @param {number} params.curveCentreX 波形中心位置(其实就是当前鼠标位置)
* @param {number} params.menuItemX 当前菜单位置(菜单中心点位置)
* @returns {number} 最终返回对应菜单的放大比例
*/
const scaleCurve = ({ curveCentreX, menuItemX }) => {
const beginX = curveCentreX - (curveRange / 2); // 波形开始的 x 位置
const endX = curveCentreX + (curveRange / 2); // 波形结束的 x 位置
// 边界控制, 目的是只保留一个波形
if (menuItemX < beginX || menuItemX > endX) {
return minScale;
}
const amplitude = maxScale - minScale; // 波形的振幅, 控制菜单项放大
const angle = ((menuItemX - beginX) / curveRange) * Math.PI; // 波形角度
return (Math.sin(angle) * amplitude) + minScale;
};
- 调用波形函数, 计算出每个菜单放大比例
diff
....
const scaleCurve = ({ curveCentreX, menuItemX }) => {
....
}
const Item = ({ color, clientX }) => {
+ const ref = useRef(null);
+ const scale = useMemo(() => {
+ if (!ref.current) {
+ return minScale;
+ }
+ const { left, width } = ref.current.getBoundingClientRect();
+ return scaleCurve({
+ curveCentreX: clientX,
+ menuItemX: left + (width / 2),
+ });
+ }, [clientX]);
return (
<div
ref={ref}
className={scss.item}
style={{ backgroundColor: color }}
/>
);
};
- 样式设置: 设置
CSS
变量、并在样式中调用变量动态调整菜单大小以及边距
diff
const Item = ({ color, clientX }) => {
...
return (
<div
ref={ref}
className={scss.item}
+ style={{ 'backgroundColor': color, '--scale': scale }}
/>
);
};
diff
.item {
border-radius: 8px;
+ width: calc(var(--scale) * $size);
+ height: calc(var(--scale) * $size);
+ margin-left: calc(var(--scale) * 5px);
+ margin-right: calc(var(--scale) * 5px);
+ margin-bottom: calc(var(--scale) * 15px - 15px);
}
到此基本的动画效果已经出来了:

三、过渡效果
上文我们完成最重要的动画效果, 但是目前还有一个问题: 鼠标移入/移出 Dock
栏, 整个动画是没有过渡效果的, 所以整体动画显得很生硬

最初这里我想法很简单, 直接给菜单加个 CSS
过渡属性即可
diff
.item {
...
+ transition: all 0.4s;
...
}
然而, 移入/移出 Dock
栏过渡效果是有了, 但是鼠标移动过程中因为有过渡动画的存在, 就会导致整体动画延迟, 显然这不是我们想要的

所以我们要做的就是, 保证在移入/移出 Dock
栏时菜单是存在过渡效果, 其余时间则不需要过渡效果。下面直接看代码: 还是通过 CSS
变量来做, 在鼠标移入/移出 Dock
栏时设置过渡动画时长为 0.08s
, 100ms
后再次设置为 0
, 即不要过渡动画
diff
export default () => {
const [clientX, setClientX] = useState(null);
+ const wrapperRef = useRef(null);
const handleMouseEnter = useCallback((e) => {
+ wrapperRef.current.style.setProperty('--transition-duration', 0.08);
setClientX(e.clientX);
+ setTimeout(
+ () => wrapperRef.current.style.setProperty('--transition-duration', 0),
+ 100,
+ );
}, []);
const handleMouseLeave = useCallback(() => {
+ wrapperRef.current.style.setProperty('--transition-duration', 0.08);
setClientX(null);
+ setTimeout(
+ () => wrapperRef.current.style.setProperty('--transition-duration', 0),
+ 100,
+ );
}, []);
return (
<div className={scss.main}>
<div
+ ref={wrapperRef}
className={scss.wrapper}
...>
...
</div>
</div>
);
};
diff
.item {
border-radius: 8px;
+ transition: all calc(var(--transition-duration) * 1s);
}
最终效果如下: 完美

简化下代码:
diff
...
+ const setTransitionDuration = useCallback(() => {
+ wrapperRef.current.style.setProperty('--transition-duration', 0.08);
+ setTimeout(
+ () => wrapperRef.current.style.setProperty('--transition-duration', 0),
+ 80,
+ );
+ }, []);
const handleMouseEnter = useCallback((e) => {
+ setTransitionDuration();
setClientX(e.clientX);
+ }, [setTransitionDuration]);
const handleMouseMove = useCallback((e) => {
setClientX(e.clientX);
}, []);
const handleMouseLeave = useCallback(() => {
+ setTransitionDuration();
setClientX(null);
+ }, [setTransitionDuration]);
....
四、一个小 BUG
如下图所示, 当鼠标移动至图示位置, 整个动效的停止了! 主要是因为当鼠标放置图示位置时, 由于菜单之间的间距是由 margin
撑开的, 所以鼠标移到该位置其实就算是移出 Dock
栏了, 也就是会触发 MouseLeave
事件, 所以自然的整个动画就结束了!

看下动态的效果:

最简单的做法就是菜单之间的间距通过 div
撑开的, 而 div
也是在 Dock
内的, 所以当鼠标移动到它上面一样会触发 MouseMove
事件, 如下代码所示:
- 新增
Gap
组件, 内部逻辑和Item
组件基本一致 Gap
组件动效和Item
也基本一致, 所以这边直接基于Item
组件做了些小调整即可
diff
...
+ const Gap = ({ clientX }) => {
+ const ref = useRef(null);
+
+ const scale = useMemo(() => {
+ if (!ref.current) {
+ return minScale;
+ }
+
+ const { left, width } = ref.current.getBoundingClientRect();
+
+ return scaleCurve({
+ curveCentreX: clientX,
+ menuItemX: left + (width / 2),
+ });
+ }, [clientX]);
+
+ return (
+ <div
+ ref={ref}
+ className={scss.gap}
+ style={{ '--scale': scale }}
+ />
+ );
+ };
export default () => {
...
return (
<div className={scss.main}>
<div ...>
{data.map((v, index) => (
+ <>
+ <Item
+ color={v.color}
+ clientX={clientX}
+ />
+ {index < data.length - 1 ? <Gap clientX={clientX} /> : null}
+ </>
))}
</div>
</div>
);
};
diff
.item {
border-radius: 8px;
transition: all calc(var(--transition-duration) * 1s);
width: calc(var(--scale) * $size);
height: calc(var(--scale) * $size);
- margin-left: calc(var(--scale) * 5px);
- margin-right: calc(var(--scale) * 5px);
margin-bottom: calc(var(--scale) * 15px - 15px);
}
+ .gap {
+ transition: all calc(var(--transition-duration) * 1s);
+
+ width: calc(var(--scale) * 10px);
+ height: calc(var(--scale) * $size);
+ margin-bottom: calc(var(--scale) * 15px - 15px);
+ }
最后效果如下:

最后我们代码做下简化, 上文中 Gap
和 Item
中很多逻辑其实是相同的, 这里我们把重复的逻辑拎出来, 抽离出一个单独的 hook
diff
+ const useScale = (clientX) => {
+ const ref = useRef(null);
+
+ const scale = useMemo(() => {
+ if (!ref.current) {
+ return minScale;
+ }
+
+ const { left, width } = ref.current.getBoundingClientRect();
+
+ return scaleCurve({
+ curveCentreX: clientX,
+ menuItemX: left + (width / 2),
+ });
+ }, [clientX]);
+
+ return { ref, scale };
+ };
const Item = ({ color, clientX }) => {
+ const { ref, scale } = useScale(clientX);
return (
<div
ref={ref}
className={scss.item}
style={{ 'backgroundColor': color, '--scale': scale }}
/>
);
};
const Gap = ({ clientX }) => {
+ const { ref, scale } = useScale(clientX);
return (
<div
ref={ref}
className={scss.gap}
style={{ '--scale': scale }}
/>
);
};
五、完整代码
组件入口代码如下:
js
/* eslint-disable no-unused-vars */
import React, { useCallback, useMemo, useRef, useState } from 'react';
import scss from './index.module.scss';
const data = [
{ color: '#ff4d4f' },
{ color: '#ff7a45' },
{ color: '#ffa940' },
{ color: '#ffc53d' },
{ color: '#ffec3d' },
{ color: '#bae637' },
{ color: '#73d13d' },
{ color: '#36cfc9' },
{ color: '#4096ff' },
{ color: '#597ef7' },
{ color: '#9254de' },
{ color: '#f759ab' },
];
const curveRange = 600;
const minScale = 1;
const maxScale = 1.8;
/**
* 比例波形
*
* @param {*} params 参数
* @param {number} params.curveCentreX 波形中心位置(其实就是当前鼠标位置)
* @param {number} params.menuItemX 当前菜单位置(菜单中心点位置)
* @returns {number} 最终返回对应菜单的放大比例
*/
const scaleCurve = ({ curveCentreX, menuItemX }) => {
const beginX = curveCentreX - (curveRange / 2); // 波形开始的 x 位置
const endX = curveCentreX + (curveRange / 2); // 波形结束的 x 位置
// 边界控制, 目的是只保留一个波形
if (menuItemX < beginX || menuItemX > endX) {
return minScale;
}
const amplitude = maxScale - minScale; // 波形的振幅, 控制菜单项放大
const angle = ((menuItemX - beginX) / curveRange) * Math.PI; // 波形角度
return (Math.sin(angle) * amplitude) + minScale;
};
const useScale = (clientX) => {
const ref = useRef(null);
const scale = useMemo(() => {
if (!ref.current) {
return minScale;
}
const { left, width } = ref.current.getBoundingClientRect();
return scaleCurve({
curveCentreX: clientX,
menuItemX: left + (width / 2),
});
}, [clientX]);
return { ref, scale };
};
const Item = ({ color, clientX }) => {
const { ref, scale } = useScale(clientX);
return (
<div
ref={ref}
className={scss.item}
style={{ 'backgroundColor': color, '--scale': scale }}
/>
);
};
const Gap = ({ clientX }) => {
const { ref, scale } = useScale(clientX);
return (
<div
ref={ref}
className={scss.gap}
style={{ '--scale': scale }}
/>
);
};
export default () => {
const [clientX, setClientX] = useState(null);
const wrapperRef = useRef(null);
const setTransitionDuration = useCallback(() => {
wrapperRef.current.style.setProperty('--transition-duration', 0.08);
setTimeout(
() => wrapperRef.current.style.setProperty('--transition-duration', 0),
80,
);
}, []);
const handleMouseEnter = useCallback((e) => {
setTransitionDuration();
setClientX(e.clientX);
}, [setTransitionDuration]);
const handleMouseMove = useCallback((e) => {
setClientX(e.clientX);
}, []);
const handleMouseLeave = useCallback(() => {
setTransitionDuration();
setClientX(null);
}, [setTransitionDuration]);
return (
<div className={scss.main}>
<div
ref={wrapperRef}
className={scss.wrapper}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}>
{data.map((v, index) => (
<>
<Item
color={v.color}
clientX={clientX}
/>
{index < data.length - 1 ? <Gap clientX={clientX} /> : null}
</>
))}
</div>
</div>
);
};
所有样式:
scss
.main {
width: 100%;
height: 100%;
position: relative;
background: center url("./bg.jpg");
background-size: cover;
}
$size: 60px;
.wrapper {
border-radius: 8px;
backdrop-filter: blur(5px);
background-color: rgba($color: #fff, $alpha: 10%);
height: $size;
padding: 10px;
display: inline-flex;
align-items: flex-end;
left: 50%;
bottom: 10px;
position: absolute;
transform: translateX(-50%);
}
.item {
border-radius: 8px;
transition: all calc(var(--transition-duration) * 1s);
width: calc(var(--scale) * $size);
height: calc(var(--scale) * $size);
margin-bottom: calc(var(--scale) * 15px - 15px);
}
.gap {
transition: all calc(var(--transition-duration) * 1s);
width: calc(var(--scale) * 10px);
height: calc(var(--scale) * $size);
margin-bottom: calc(var(--scale) * 15px - 15px);
}