【组件封装】为元素出现在视口添加动画

295 阅读2分钟

一、实现目标

为元素出现在视口时添加动画,这是很常见的需求,最近在工作中遇到了,项目里面使用的是React框架。因此自己封装了一个组件。如果是Vue框架,可以封装一个自定义指令来实现,更方便一点。

二、实现思路

使用IntersectionObserver API 监听元素是否出现在视口,使用animate API为元素添加动画

三、实现过程

import classNames from 'classnames';
import { CSSProperties, useEffect, useRef } from 'react';

interface IntersectionWithAnimationBoxProp {
  /**
   * 子节点
   *
   * @type {JSX.Element}
   * @memberOf AnimationBoxProp
   */
  children: JSX.Element;
  /**
   * 动画效果
   *
   * @type {{ duration: number; fill: string }}
   * @memberOf AnimationBoxProp
   */
  optionalEffectTiming?: OptionalEffectTiming;
  /**
   * 关键帧数组
   *
   * @type {Keyframe[]}
   * @memberOf AnimationBoxProp
   */
  keyframes?: Keyframe[];
  /**
   * 类名
   *
   * @type {string}
   * @memberOf AnimationBoxProp
   */
  className?: string;
  /**
   * 样式
   *
   * @type {CSSProperties}
   * @memberOf AnimationBoxProp
   */
  style?: CSSProperties;
  /**
   * 默认关键帧, 可选
   *
   * @type {('left' | 'right' | 'top' | 'bottom')}
   * @memberOf AnimationBoxProp
   */
  defaultKeyFramePosition?: 'left' | 'right' | 'top' | 'bottom';
}

/**
 * 给元素添加效果(元素出现在视口, 执行动画)的容器, 函数re-render阶段不会重复执行动画, 只在挂载阶段执行一次
 */
export default function IntersectionWithAnimationBox(
  prop: IntersectionWithAnimationBoxProp
) {
  const {
    children,
    optionalEffectTiming = { duration: 300, fill: 'forwards' },
    keyframes,
    className,
    style,
    defaultKeyFramePosition = 'bottom',
  } = prop;
  const container = useRef<HTMLDivElement>(null);

  // 获取关键帧数组
  const getKeyFrames = (): Keyframe[] => {
    if (keyframes && !!keyframes.length) return keyframes;

    switch (defaultKeyFramePosition) {
      case 'bottom':
        return [
          {
            opacity: 0,
            transform: 'translateY(100%)',
          },
          {
            opacity: 1,
            transform: 'translateY(0)',
          },
        ];
      case 'top':
        return [
          {
            opacity: 0,
            transform: 'translateY(-100%)',
          },
          {
            opacity: 1,
          },
        ];
      case 'left':
        return [
          {
            opacity: 0,
            transform: 'translateX(-100%)',
          },
          {
            opacity: 1,
            transform: 'translateX(0)',
          },
        ];
      case 'right':
        return [
          {
            opacity: 0,
            transform: 'translateX(100%)',
          },
          {
            opacity: 1,
            transform: 'translateX(0)',
          },
        ];
    }
  };

  const addOpacity = () => {
    container.current && container.current.classList.add('opacity-100');
  };

  // 执行动画
  const animate = (element: Element) => {
    if (element instanceof HTMLElement) {
      const animation = element.animate(getKeyFrames(), optionalEffectTiming);

      animation.finished.then(() => {
        addOpacity();
      });
    }
  };

  // 通过IntersectionObserver API 监听元素是否出现在视口
  useEffect(() => {
    if (!container.current) return;

    const io = new IntersectionObserver((entries) => {
      entries.forEach(async (item) => {
        if (item.isIntersecting) {
          io.unobserve(item.target);
          animate(item.target);
        }
      });
    });
    io.observe(container.current);

    return () => {
      container.current && io.unobserve(container.current);
    };
  }, []);

  return (
    <div ref={container} className={classNames(['opacity-0', className])} style={style}>
      {children}
    </div>
  );
}

四、如何使用

1. 单个元素场景

使用封装好的组件直接包裹就可以

  <IntersectionWithAnimationBox optionalEffectTiming={{ duration: 2000 }}>
     <div className="h-40 w-40 rounded-lg bg-theme-primary"></div>
  </IntersectionWithAnimationBox>

2. 多个元素场景

一般来说,多个元素场景需要有一个动画时间的递进关系,这个只需要改变delay就可以,可以利用index动态改变deley时间

      <div className="flex gap-x-5">
        {DATA.map((data, index) => (
          <IntersectionWithAnimationBox
            optionalEffectTiming={{
              duration: 2000,
              delay: index * 150 + 150,
              easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)',
            }}
            className="h-40"
            key={data}
          >
            <div className="rounded-lg bg-theme-primary p-6 text-center">{data}</div>
          </IntersectionWithAnimationBox>
        ))}
      </div>

五、总结

优点

封装为slot的形式,在使用上会方便很多,使用者无需去关心内部实现的逻辑,只需将动画传入或者选中默认的动画效果即可。

缺点

使用该组件包裹后,最终渲染会多了一个div标签。但是一般来说不会影响,如果有class的问题,可以使用prop:className将样式传递进去。