还原 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);
}

六、参考

相关推荐
恋猫de小郭13 分钟前
腾讯 Kuikly 正式开源,了解一下这个基于 Kotlin 的全平台框架
android·前端·ios
2301_7994049115 分钟前
如何修改npm的全局安装路径?
前端·npm·node.js
(❁´◡双辞`❁)*✲゚*20 分钟前
node入门和npm
前端·npm·node.js
韩明君24 分钟前
前端学习笔记(四)自定义组件控制自己的css
前端·笔记·学习
tianchang35 分钟前
TS入门教程
前端·typescript
吃瓜群众i35 分钟前
初识javascript
前端
吃面必吃蒜1 小时前
从 Vue 到 React:React 合成事件
javascript·vue.js·react.js
前端练习生1 小时前
vue2如何二次封装表单控件如input, select等
前端·javascript·vue.js
NoneCoder1 小时前
HTML与Web 性能优化:构建高速响应的现代网站
前端·性能优化·html