阅读 1751

动画特辑(一) 音量弹射🔊

导语

动画是前端工作中很重要的一部分,有趣的交互动画实现常常能让用户刚到惊艳,兴奋,并更愿意使用你的产品。熟练掌握动画是前端工程师的必备技能之一。这篇文章将从零开始介绍如何实现一个音量发射的动画。

我的实现

正题

前两天看到朋友圈有朋友分享了个链接——你见过最反人类的设计是什么?。里面列了很多反人类的【音量调节】的设计,感兴趣的可以点进去看看。👀

吸引我的是下面这个,【音量弹射】,按住音量图标,抬升炮筒,松开鼠标发射设置音量,实在是太有趣了。

着急的朋友可以先看最终效果

分解

这个动画大致可以分成两个部分来做,🔊音量图标和弹球Bar,程序设计的时候可以拆分成两个独立模块来做。

<VolumnShotter />
    - state: 
      - volumn // 音量值
      - increacing // 是否在蓄力过程中
    <Shotter />
        - props: 
            - volumn
            - onMouseDown/onMouseUp
    <BallBar />
        - props:
            - increacing
        - methods:
            - play(volumn: number)
复制代码

<Shotter /> 组件根据 props.volumn 值控制旋转角度和 bubbleSize,同时响应 mouse 事件。<BallBar /> 组件由 props.increacing 控制小球的展示与否,提供 play 方法供调用。两个组件内根据 volumn 值做不同状态的转换映射。

<Shotter />

这里需要用到一个 SVG 的音量图标,直接上 FlatIcon 上搜索,找到合适的图标下载下来。

FlatIcon

按压的时候,音量内部会有一个小球缩放的效果,需要用到遮罩效果。可以拖到 Sketch 里面,使用蒙版功能来做。

点击编组进行 SVG 导出,得到一份代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<svg width="236px" height="221px" viewBox="0 0 236 221" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <title>编组</title>
    <defs>
        <path d="M137.534889,0.782686392 C134.789556..." id="path-1"></path>
    </defs>
    <g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
        <g>
            <mask id="mask-2" fill="white">
                <use xlink:href="#path-1"></use>
            </mask>
            <use fill="#D8D8D8" xlink:href="#path-1"></use>
            <circle fill="#F7B500" mask="url(#mask-2)" cx="0" cy="110" r="71"></circle>
        </g>
    </g>
</svg>
复制代码

看到最终导出的代码里面用到了 SVG 的 mask 标签来做遮罩效果。从 CanIUse 上看这个 feature 支持 75.17% 的浏览器。

把 SVG 代码拷贝导入到 React 组件代码里面,将传入的 props.volumn 值动态修改旋转角度和 circle 元素的半径。

import React from "react";
import { mapping } from "./math";

export default (props: Props) => {
  const { volumn = 0, ...rest } = props;
  const circleRadius = mapping(volumn, 0, 100, 0, 240);
  const transform = `rotate(${mapping(volumn, 0, 100, 0, -45)}deg)`;

  return (
    <svg
      // ...
      style={{
        transform
      }}
      {...rest}
    >
      // ...
      <circle
        r={circleRadius}
      />
    </svg>
  );
};
复制代码

这里用到了简单的值映射,将 0~100 的 volumn 映射到 circle 半径 0~240,旋转角度 0~-45deg。值映射的方法如下:

export const mapping = (num, inMin, inMax, outMin, outMax) => {
  return ((num - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
};
复制代码

然后动态修改的 volumn 值这一部分动画就完成了。

Shotter 效果

<BallBar />

这个组件需要对外暴露一个 play 方法,我们使用 React Hook 的写法需要如何提供方法?答案是 useImperativeHandle + forwardRef

export default React.forwardRef(({ increacing }: Props, ref) => {
  useImperativeHandle(ref, () => ({
    play: volumn => {
      console.log(volumn)
    }
  }));

  return (
    <div className="ball-bar">
        //...
    </div>
  );
});
复制代码

用的时候是在给组件传入 ref, <BallBar ref={ballBarRef}>,通过 ref 调用方法 ballBarRef.current.play(column)

接着进入实现小球动画的部分,小球的起始状态包括起始高度,起始速度(向量),结束位置等等信息都是由 volumn 决定的,可以在 play 方法里面可以计算得到。

通过向量分解,以及三角函数基础就可以计算出需要的数据。如果对三角函数运算不太熟悉的朋友可以看我之前写的另一篇文章三角函数在前端动画中的应用

const radians = angleToRadians(mapping(volumn, 0, 100, 0, -45));
const startY = Math.tan(radians) * 54;
const startX = 0;

// 音量越高初始速度越大。忽略这里的 magic number
const initalVelocity = mapping(volumn, 0, 100, 0, 999);
const vx = initalVelocity * Math.cos(radians);
const vy = initalVelocity * Math.sin(radians)
const endX = mapping(volumn, 0, 100, 0, barRef.current.clientWidth);
const endY = 0;
复制代码

起始状态、结束位置都有了。小球动画实现可以有两种方式,一种是基于 SVG Path 动态生成路劲,然后让小球按照一定的 Easing Function 曲线移动。第二种是基于物理模拟的库俩做,这个 Demo 使用 ReactSping

useSpring({
    from: { y: startY }, // 起始位置
    to: { y: endY }, // 目标位置
    reset: resetRef.current, // 每次 play 都要重置
    config: {
      velocity: vy, // 起始速度
      friction: 10, // 摩擦力,忽略 magic number
      tension: 60, // 拉力,忽略 magic number
      clamp: true // 一旦超过范围立即停下来
    }
  });
复制代码

再把 useSpring 值应用到 animated.div 上就可以了。

{increacing ? null : (
    <animated.div
      className="ball"
      style={{
        transform: y.interpolate(
          value => `translateY(${value}px)`
        )
      }}
    />
 )}
复制代码

ReactSpring 用起来也很简单,更多配置项可以查阅文档

<VolumnShotter />

再把两个组件串起来。监听 Shottermouse 事件,当按压开始时启动 requestAnimationFrame,每一个 tick 修改 volumn 值。

useEffect(() => {
    function tick() {
      setVolumn(v => clamp(v + speed, 0, 100));
      reqRef.current = requestAnimationFrame(() => { tick() });
    }

    if (increacing) {
      tick();
    } else {
      setVolumn(0);
    }

    return () => {
      cancelAnimationFrame(reqRef.current);
    };
  }, [increacing, speed]);
复制代码

mouseUp 的时候,调用 play 方法将 volumn 传递过去。

const toggle = () => {
    if (increacing && barRef.current) {
        (barRef.current as any)!.play(volumn);
    }
};
复制代码

把上面所有知识点结合起来,就可以实现有趣的音量弹射动画了,完整代码可以在 CodeSandBox 上看到。

我的实现

结语

为了快速实现,这个例子中硬编码了一些 Magic Number,这是不好的示范,真正在实际项目中应该考虑扩展性,可维护性。

还有要让 Volumn 展示更准确的话还需要调参~

意外的收获

个人爱好,我平时都会收藏一些好看的图片,有趣的动画等等,有时间的时候会尝试去实现一些效果/动画。16 年一月份的时候,我在 Dribbble 上看到一个粉刷屏幕的动画,觉得很好玩,应用当时新学到的知识尝试去实现它,然后发布到 Codepen 上。后来实习的时候,恰巧面试官刚好看到过我的实现,于是我就很顺利地拿下实习 offer。

感兴趣的朋友可以关注我的掘金,后续我会计划多写一些动画入门级别的文章。

公众号