react hook封裝購物車動畫

前陣子,開發(fā)過程中需要用到購物車動畫,所以封裝了動畫hooks,在此做一下總結(jié)歸納。

一、思考

首先,購物車動畫的軌跡是一個拋物線效果,這個我們可以通過CSS動畫來實現(xiàn)。其次,我們的拋物線需要一個起始點、一個目標點、一個運動小球。
然后,通過計算起始點和目標點兩者之間 x 軸和 y 軸的距離,然后通過 CSS 來改變運動小球的位置和移動速度,從而實現(xiàn)加入購物車效果。

思考框架.png

那么,這個拋物線動畫效果如何實現(xiàn)?

高中物理告訴我們,當物體運動時,X軸方向上和Y軸方向上的速度不一致時,物體的運動效果就是拋物線,類似我們向外拋球,小球的運動軌跡。

所以,想要有拋物線效果,我們只需要控制運動小球,從起始點運動到目標點的過程中,X軸和Y軸方向上的速度不一致即可。

因此,我們可以通過X軸方向上的速度不變,通過Y軸方向上的速度變化。

那么,如何控制Y軸上的速度變化?

搜索前端 CSS 樣式,我們可以發(fā)現(xiàn),可以使用 transition-timing-function: linear|ease|ease-in|ease-out|ease-in-out|cubic-bezier(n,n,n,n);
屬性來實現(xiàn)過渡效果的速度變化。

其中,三階貝塞爾曲線cubic-bezier(x1, y1, x2, y2): 四個參數(shù)值分別在 0 到 1 之間,其中 (x1, y1)(x2, y2) 是控制曲線的變化程度。

快點擊鏈接,去玩玩這個曲線吧!可好玩了!

什么是貝塞爾曲線?快去了解它??!

二、基本框架

我們思考一下,想要把這個動畫效果封裝起來通用,我們需要傳入哪些必傳參數(shù)? 需要暴露哪些參數(shù)或方法給外層組件調(diào)用? 需要提供哪個參數(shù)便于個性化擴展?

  1. 需要起始Dom節(jié)點、目標Dom節(jié)點;
  2. 需要暴露running方法,用于開啟動畫效果;
  3. 需要運動小球,小球包含兩層,外層flyOuter控制X軸勻速運動,內(nèi)層flyInner控制Y軸變速運動;
  4. 需要提供屬性,支持自定義小球的內(nèi)容children、小球內(nèi)外層樣式 flyOuterStyle / flyInnerStyle 、小球運動時間設(shè)置runTime、小球開始運動回調(diào)beforeRun、小球開始運動回調(diào)afterRun

hook封裝實現(xiàn)

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

import ReactDOM from 'react-dom';

/**
 * 動畫球
 * @params children - 小球擴展內(nèi)容
 * @params flyOuterStyle - 小球外層擴展樣式
 * @params flyInnerStyle - 小球內(nèi)層擴展樣式
 * @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,
    );

    // 運動小球內(nèi)層樣式
    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節(jié)點
 * @params endRef - 目標點dom節(jié)點
 * @params flyOuterStyle - 小球外層擴展樣式
 * @params flyInnerStyle - 小球內(nèi)層擴展樣式
 * @params runTime - 小球運動時間
 * @params beforeRun - 小球開始運動回調(diào)
 * @params afterRun - 小球結(jié)束運動回調(diào)
 * @params children - 小球擴展內(nèi)容
 * @returns { running } - 小球開始運動函數(shù)
 */
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;

      // 現(xiàn)在起點距離終點的距離
      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 };
}

三、測試用例

實現(xiàn)效果:


購物車動畫.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號球運動結(jié)束啦啦~~' });
      },
    },
    <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}>
            發(fā)射中心
          </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實現(xiàn)拋物線運動效果

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容