还原 Mac Dock 栏动效: 一步步打造流畅的波形缩放动画

引言

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

是的, 动效其实就是想抄 Mac Dock 栏。但是整体动画效果还是差了点意思, 目前的做法比较简单:

  1. 监听鼠标事件, 判断下目前鼠标悬停在哪个菜单项上
  2. 然后就是将前后几个菜单设置不同的一个缩放比例

而实际动效应该是下面这样的, 整个动画应该是流畅的, 并且即便鼠标是在同一个菜单上移动, 同样也是会对整个动画造成影响

下面就开始一步步实现该效果....

相关资料:

  1. Github 源码
  2. 预览效果

一、整体布局

开始前, 我们先把基本的布局撸出来

如下代码, 先创建一个容器, 先整个好看的背景

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 栏先整出来:

  1. 新增一个 div 节点
  2. 毛玻璃实现: 样式中通过 backdrop-filter 属性将 Dock 背景毛玻璃化
  3. 定位: 通过 position + bottomDock 栏置底, 通过 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%);
+ }

效果大概如下:

最后我们把所以菜单加上:

  1. 造一批数据, 将其作为菜单进行渲染出来
  2. 使用 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 波形函数可以怎么实现?

如下图所示, 在 MacDock 栏, 当我们鼠标放置在某个菜单上时, 菜单相邻的左右两边若干个菜单, 无论大小、间距都是呈现一个波形

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 动画实现

整个动画实现还是比较简单的:

  1. 监听鼠标事件, 获取当前鼠标相对于视口的横向位置, 该点即整个波形的中心点
  2. 通过波形的中心点, 即可计算出整个波形的开始位置和结束位置(假定波形的范围为 600)
  3. 最后我们就可以通过每个菜单的位置, 以及波形的开始位置和结束位置, 计算出每个坐落在波形范围内菜单的 放大比例
  4. 得到每个菜单的 放大比例 后, 通过 CSS 变量的方式, 将每个菜单放大比例值应用到样式上, 包括控制菜单的整体大小、间距
  1. 首先先把相关事件绑定上: 鼠标移动时获取鼠标位置、并传给每个菜单组件
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>
  );
};
  1. 创建波形函数: 根据当前波形中心位置(当前鼠标位置)以及当前菜单位置, 计算出该菜单放大的比例
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;
};
  1. 调用波形函数, 计算出每个菜单放大比例
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 }}
    />
  );
};
  1. 样式设置: 设置 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);
+ }

最后效果如下:

最后我们代码做下简化, 上文中 GapItem 中很多逻辑其实是相同的, 这里我们把重复的逻辑拎出来, 抽离出一个单独的 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);
}

六、参考

相关推荐
崔庆才丨静觅5 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了6 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅6 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅7 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊7 小时前
jwt介绍
前端
爱敲代码的小鱼7 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax