react hook封装购物车动画

前阵子,开发过程中需要用到购物车动画,所以封装了动画hooks,在此做一下总结归纳。

一、思考

首先,购物车动画的轨迹是一个抛物线效果,这个我们可以通过CSS动画来实现。其次,我们的抛物线需要一个起始点、一个目标点、一个运动小球
然后,通过计算起始点和目标点两者之间 x 轴和 y 轴的距离,然后通过 CSS 来改变运动小球的位置和移动速度,从而实现加入购物车效果。

思考框架.png

那么,这个抛物线动画效果如何实现?

高中物理告诉我们,当物体运动时,X轴方向上和Y轴方向上的速度不一致时,物体的运动效果就是抛物线,类似我们向外抛球,小球的运动轨迹。

所以,想要有抛物线效果,我们只需要控制运动小球,从起始点运动到目标点的过程中,X轴和Y轴方向上的速度不一致即可。

因此,我们可以通过X轴方向上的速度不变,通过Y轴方向上的速度变化。

那么,如何控制Y轴上的速度变化?

搜索前端 CSS 样式,我们可以发现,可以使用 transition-timing-function: linear|ease|ease-in|ease-out|ease-in-out|cubic-bezier(n,n,n,n);
属性来实现过渡效果的速度变化。

其中,三阶贝塞尔曲线cubic-bezier(x1, y1, x2, y2): 四个参数值分别在 0 到 1 之间,其中 (x1, y1)(x2, y2) 是控制曲线的变化程度。

快点击链接,去玩玩这个曲线吧!可好玩了!

什么是贝塞尔曲线?快去了解它!!

二、基本框架

我们思考一下,想要把这个动画效果封装起来通用,我们需要传入哪些必传参数? 需要暴露哪些参数或方法给外层组件调用? 需要提供哪个参数便于个性化扩展?

  1. 需要起始Dom节点、目标Dom节点;
  2. 需要暴露running方法,用于开启动画效果;
  3. 需要运动小球,小球包含两层,外层flyOuter控制X轴匀速运动,内层flyInner控制Y轴变速运动;
  4. 需要提供属性,支持自定义小球的内容children、小球内外层样式 flyOuterStyle / flyInnerStyle 、小球运动时间设置runTime、小球开始运动回调beforeRun、小球开始运动回调afterRun

hook封装实现

import React, { useRef, useEffect, useImperativeHandle } from 'react';

import ReactDOM from 'react-dom';

/**
 * 动画球
 * @params children - 小球扩展内容
 * @params flyOuterStyle - 小球外层扩展样式
 * @params flyInnerStyle - 小球内层扩展样式
 * @params runTime - 小球运动时间
 * @params ref - 小球dom实例
 */
const flyOuter = React.forwardRef(
  ({ children, flyOuterStyle = {}, flyInnerStyle = {}, runTime = 0.8 }, ref) => {
    const flyOuterRef = useRef();
    const flyInnerRef = useRef();
    useImperativeHandle(ref, () => ({ flyOuterRef, flyInnerRef }));


    // 运动小球外层样式
    const flyOuter_Style = Object.assign(
      {
        position: 'absolute',
        width: '20px',
        height: '20px',
        transition: `transform ${runTime}s`,
        display: 'none',
        margin: ' -20px 0 0 -20px',
        transitionTimingFunction: 'linear',
        zIndex: 3,
      },
      flyOuterStyle,
    );

    // 运动小球内层样式
    const flyInner_Style = Object.assign(
      {
        position: 'absolute',
        width: '100%',
        height: '100%',
        borderRadius: '50%',
        backgroundColor: '#FF8A2B',
        color: '#ffffff',
        textAlign: 'center',
        lineHeight: '1',
        transition: `transform ${runTime}s`,
        justifyContent: 'center',
        alignItems: 'center',
        // transitionTimingFunction: 'cubic-bezier(.55,0,.85,.36)', // 向上抛物线的右边
        transitionTimingFunction: 'cubic-bezier(0, 0, .25, 1.3)', // 向下抛物线的左边
      },
      flyInnerStyle,
    );

    return (
      <div style={flyOuter_Style} ref={flyOuterRef}>
        <div style={flyInner_Style} ref={flyInnerRef}>
          {children}
        </div>
      </div>
    );
  },
);


/**
 * 抛物线动画效果
 * @params startRef - 起始点dom节点
 * @params endRef - 目标点dom节点
 * @params flyOuterStyle - 小球外层扩展样式
 * @params flyInnerStyle - 小球内层扩展样式
 * @params runTime - 小球运动时间
 * @params beforeRun - 小球开始运动回调
 * @params afterRun - 小球结束运动回调
 * @params children - 小球扩展内容
 * @returns { running } - 小球开始运动函数
 */
export default function useParabola(
  {
    startRef,
    endRef,
    flyOuterStyle,
    flyInnerStyle,
    runTime = 800,
    beforeRun = () => {},
    afterRun = () => {},
  },
  children,
) {
  const containerRef = useRef(document.createElement('div'));
  const innerRef = useRef();
  let isRunning = false;

  // 挂载到dom上
  useEffect(() => {
    const container = containerRef.current;
    document.body.appendChild(container);
    return () => {
      document.body.removeChild(container);
    };
  }, []);


  useEffect(() => {
    if (startRef?.current && endRef?.current) {
      ReactDOM.render(
        React.createElement(
          flyOuter,
          { ref: innerRef, flyOuterStyle, flyInnerStyle, runTime: runTime / 1000 },
          children,
        ),
        containerRef.current,
      );
    }
  }, [startRef, endRef]); // eslint-disable-line

  function running() {
    if (startRef && endRef && innerRef) {
      beforeRun();
      const flyOuterRef = innerRef.current.flyOuterRef.current;
      const flyInnerRef = innerRef.current.flyInnerRef.current;

      // 现在起点距离终点的距离
      const startDot = startRef.current.getBoundingClientRect();
      const endDot = endRef.current.getBoundingClientRect();

      // 中心点的水平垂直距离
      const offsetX = endDot.left + endDot.width / 4 - (startDot.left + startDot.width / 2);
      // let offsetY = endDot.top + endDot.height / 2 - (startDot.top + startDot.height / 2);
      const offsetY = endDot.top + endDot.height / 4 - (startDot.top + startDot.height / 2);

      // 页面滚动尺寸
      const scrollTop = document.documentElement.scrollTop || document.body.scrollTop || 0;
      const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft || 0;
      if (!isRunning) {
        // 初始定位
        flyOuterRef.style.display = 'block';
        flyOuterRef.style.left = `${
          startDot.left + scrollLeft + startRef.current.clientWidth / 2
        }px`;
        flyOuterRef.style.top = `${startDot.top + scrollTop + startRef.current.clientHeight / 2}px`;

        // 开始动画
        flyOuterRef.style.transform = `translateX(${offsetX}px)`;
        flyInnerRef.style.transform = `translateY(${offsetY}px)`;

        // 动画标志量
        isRunning = true;
        setTimeout(() => {
          flyOuterRef.style.display = 'none';
          flyOuterRef.style.left = '';
          flyOuterRef.style.top = '';
          flyOuterRef.style.transform = '';
          flyInnerRef.style.transform = '';
          isRunning = false;

          afterRun();
        }, runTime);
      }
    }
  }

  return { running };
}

三、测试用例

实现效果:


购物车动画.gif

js代码

import React, { useRef, useState } from 'react';
import { Button, notification } from 'antd';
import { ShoppingCartOutlined, PayCircleOutlined } from '@ant-design/icons';
import useParabola from '@/hooks/use-parabola';
import styles from './index.less';

/*
 * @Description: 购物车动画-demo
 * @version: 0.0.1
 * @Date: 2020-04-20 23:21:33
 */
export default React.forwardRef(() => {
  const [num, setNum] = useState(1);

  const startRef = useRef();
  const endRef_1 = useRef();
  const endRef_2 = useRef();
  const endRef_3 = useRef();
  const endRef_4 = useRef();
  const res_1 = useParabola(
    {
      startRef,
      endRef: endRef_1,
      flyOuterStyle: {
        width: '40px',
        height: '40px',
        transition: 'transform 3s',
        margin: ' -40px 0 0 -40px',
      },
      flyInnerStyle: {
        color: '#FF0000',
        transition: 'transform 3s',
        lineHeight: '40px',
      },
      runTime: 3000,
      beforeRun: () => {
        notification.warning({ message: '12号球开始运动啦啦~~' });
      },
      afterRun: () => {
        notification.success({ message: '12号球运动结束啦啦~~' });
      },
    },
    <span>12</span>,
  );
  const res_2 = useParabola(
    {
      startRef,
      endRef: endRef_2,
      flyInnerStyle: {
        transitionTimingFunction: 'cubic-bezier(.55,0,.85,.36)',
      },
    },
    '2',
  );
  const res_3 = useParabola(
    {
      startRef,
      endRef: endRef_3,
      flyOuterStyle: { transition: 'transform 2.5s' },
      flyInnerStyle: { transition: 'transform 2.5s' },
      runTime: 2500,
    },
    '3',
  );
  const res_4 = useParabola(
    {
      startRef,
      endRef: endRef_4,
      flyInnerStyle: {
        transitionTimingFunction: 'cubic-bezier(.55,0,.85,.36)',
      },
    },
    '4',
  );

  function startRunning() {
    if (num % 4 === 1) {
      res_1.running(1);
    }
    if (num % 4 === 2) {
      res_2.running(2);
    }
    if (num % 4 === 3) {
      res_3.running(3);
    }
    if (num % 4 === 0) {
      res_4.running(4);
    }
    setNum(num + 1);
  }

  return (
    <div className={styles['cart-animation']}>
      <div className={styles.center}>
        <div ref={startRef}>
          <Button danger icon={<PayCircleOutlined />} onClick={startRunning}>
            发射中心
          </Button>
        </div>
      </div>

      <div className={styles.left}>
        <div ref={endRef_1}>
          <Button type="primary" icon={<ShoppingCartOutlined />} className={styles['left-top']}>
            购物车1号
          </Button>
        </div>
        <div ref={endRef_2}>
          <Button type="primary" icon={<ShoppingCartOutlined />} className={styles['left-bottom']}>
            购物车2号
          </Button>
        </div>
      </div>
      <div className={styles.right}>
        <div ref={endRef_3}>
          <Button type="primary" icon={<ShoppingCartOutlined />} className={styles['right-top']}>
            购物车3号
          </Button>
        </div>
        <div ref={endRef_4}>
          <Button type="primary" icon={<ShoppingCartOutlined />} className={styles['right-bottom']}>
            购物车4号
          </Button>
        </div>
      </div>
    </div>
  );
});

css代码

@import '~antd/lib/style/themes/default.less';

.cart-animation {
  position: relative;
  height: 300px;
  .center {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
  }
  .left,
  .right {
    position: absolute;
    top: 50px;
  }
  .left {
    left: 0;
  }
  .right {
    right: 0;
  }
  .left-top,
  .right-top {
    margin-bottom: 200px;
  }
  button {
    display: block;
  }
}

四、参考链接

小折腾:JavaScript与元素间的抛物线轨迹运动

这回试试使用CSS实现抛物线运动效果

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335