C端官网项目动画(标题动画构建/react-spring/react-gsap)

4,138 阅读9分钟

一、标题文字基础动画效果

项目中白体常用于突出页面主题或者展示一些附加信息,基本上标题相关的数据不会一大段的出现,设计上至多分为三层,标题下又时常跟随着一个段落的介绍,所以对于项目全局的动画,一般都要考虑标题入场离场,以及标题动画在某个状态时段落等其他内容块如何展示,所以在项目伊始就应该考虑标题动画的相关控制,包括但不限于开始、暂停、结束、倒退以及对应各种状态的监听。

标题动画技术选型

工具优点缺点
react-spring弹性动画模型,可以做到很逼真的弹性物理效果,在react适配也很完善,参考网址www.react-spring.dev/难以控制动画的各个节点,因为基于弹性物理公式,在限制动画时长等方面均不是很方便,需要依据公式做相关计算
GSAP自由度更高,示例多,利用搭配别的工具做更复杂的效果,参考网址:greensock.com/get-startedapi老旧,使用不方便
animation.css是用原生写法,没有太多的封装,包体积很小,参考网址animate.style书写代码过于繁琐,需要开发者手动进行封装,同时需要写js和css,还要考虑各种细节交互
react-gsapGSAP的react封装版本,继承了GSAP的优点,同时兼容react的写法,参考网址bitworking.github.io/react-gsap对GSAP的功能继承有限,部分效果没法实现

个人摸索过程中,前期主要使用react-spring,所实现的相关功能如下

  1. 时间序列动画

关于时间序列动画,主要是约束元素保持既定的顺序进行展示,如下图所示的导航链式动画效果

Kapture 2023-02-20 at 21.10.20.gif

应用于标题如下图所示 Kapture 2023-02-20 at 21.13.19.gif

纯标题展示如下

Kapture 2023-02-20 at 21.36.28.gif

此阶段还处于标题组件的雏形阶段,后续因功能完善持续改造,陆续添加了各种参数的控制,如使用ahooks的inviewport判断节点是否在视口之中,完善组件离开视野时的显示逻辑,context初始化变量isRunMultiTime(用于全局的限制,比如针对移动端限制只展示一次)和组件属性isOnce,定义组件动画是否重复播放,整体组件代码如下

import React, { CSSProperties, ReactNode, Children, useRef, useEffect, useContext } from 'react';
import classnames from 'classnames';
import { a, useTransition, useSpringRef, useSpring } from '@react-spring/web';
import { useInViewport } from 'ahooks';

import XIcon from '@/components/xIcon';
import ConfigContext from '@/components/layout/configContext';

import { delayFunc } from '@/utils';

import PageStyles from './index.module.less';

export interface AnimationTitleProps {
   className?: string;
   title?: ReactNode;
   subTitle?: ReactNode;
   closeIcon?: ReactNode;
   delay?: number;
   style?: CSSProperties;
   animation?: boolean;
   isOnce?: boolean;
}

type TransitionProps = Parameters<typeof useTransition>[1];

const AnimationTitle = ({
   className,
   title,
   subTitle,
   closeIcon = <XIcon name='title-x' />,
   delay = 300,
   style,
   animation = true,
   isOnce = false
}: AnimationTitleProps) => {
   const { isRunMultiTime } = useContext(ConfigContext);

   const closeIconRef = useSpringRef();
   const closeIconStyle = useSpring({
      ref: closeIconRef,
      from: {
         opacity: 0,
         rotate: -100
      },
      to: {
         rotate: 0,
         opacity: 1
      }
   });

   const titleRef = useSpringRef();
   const titleStyle = useSpring({
      ref: titleRef,
      from: {
         scale: 0,
         opacity: 0
      },
      to: {
         scale: 1,
         opacity: 1
      },
      config: {
         tension: 100,
         friction: 14
      }
   });

   const renderSubTitle: ReactNode[] = Children.map(Children.toArray(subTitle), (child, index) => {
      if (typeof child === 'string') {
         return <div key={index}>{child}</div>;
      }
      return child;
   });

   const subTitleRef = useSpringRef();
   const transitionProps: TransitionProps = {
      from: {
         opacity: 0,
         y: 50
      },
      enter: (msg, i) => ({
         delay: () => {
            return i * 400;
         },
         to: {
            opacity: 1,
            y: 0
         }
      }),
      ref: subTitleRef
   };

   if (!isRunMultiTime || isOnce) {
      transitionProps.keys = (item: { key: any }) => item.key;
   }

   const transitions = useTransition(renderSubTitle, transitionProps);

   const domRef = useRef<HTMLDivElement>(null);
   const [inViewPort] = useInViewport(domRef);
   useEffect(() => {
      const startAnimation = async () => {
         await delayFunc(delay);
         if (closeIcon) {
            closeIconRef.start();
         }
         if (title) {
            await Promise.all(titleRef.start());
         }
         if (subTitle) {
            await Promise.all(subTitleRef.start());
         }
      };

      const reverseAnimation = () => {
         [closeIconRef, titleRef, subTitleRef].forEach((item) => {
            item.start({
               reverse: true
            });
         });
      };

      if (inViewPort) {
         startAnimation();
      } else if (isRunMultiTime && !isOnce) {
         reverseAnimation();
      }
      // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [inViewPort, isOnce, title, subTitle]);

   const getStyle = (style: any) => (animation ? style : undefined);

   return (
      <div ref={domRef} className={classnames(PageStyles.textDisplay, 'text-display', className)} style={style}>
         {closeIcon && (
            <a.div style={getStyle(closeIconStyle)} className={classnames(PageStyles.closeIcon, 'close-icon')}>
               {closeIcon}
            </a.div>
         )}
         {title && (
            <a.div style={getStyle(titleStyle)} className={classnames(PageStyles.title, 'text-display-title')}>
               {title}
            </a.div>
         )}
         {subTitle && (
            <div className={classnames(PageStyles.subTitleContainer, 'text-display-sub-title-container')}>
               {transitions((style, item: any) => {
                  return (
                     <a.div
                        style={getStyle(style)}
                        className={classnames(PageStyles.subTitle, 'text-display-sub-title')}
                     >
                        {item}
                     </a.div>
                  );
               })}
            </div>
         )}
      </div>
   );
};

export default AnimationTitle;
  1. 字体打印动画

顾名思义,一个单词一个字母一个字母打印展示的效果

Kapture 2023-02-20 at 21.58.23.gif

这个效果比较麻烦的是需要让每个字母链式展示,所以必然涉及字符串的拆分,一涉及到字符串拆分,麻烦就多起来了,有以下几点需要考量的细节

  • 怎么处理换行?
  • 每个字母拆出来必然涉及到dom节点过多的性能问题,该怎么处理?
  • 能否避开字母拆分,用宽度+透明度做处理?

以上几点都是值得考虑的问题,关于换行,我们可以使用上下覆盖的方式,先用一个透明的节点去占个位置,然后另一个节点设置浮动在占位的节点上且完全重合,然后再慢慢展示出来,上面的动画就是这样实现的,其代码如下

import React, { useEffect } from 'react'
import styles from './index.module.less'
import { useSprings, animated } from '@react-spring/web'

const PhotoWall = () => {
  // const [backGround, setBackGround] = useState<string>('#000')
  // 轮播当前页
  // const [current, setCurrent] = useState<number>(0)
  const testString = 'EVERYTHING IS OJBK!!'.split('')
  const animationToggle = 'on'

  const [printingSprings, pritingApi] = useSprings(testString.length, i => ({
    to: { opacity: 1 },
    from: { opacity: 0 },
    delay: i * 200,
    config: { duration: 200 }
  }))

  useEffect(() => {
    animationToggle === 'on' && pritingApi.start(i => ({ delay: i * 200, opacity: 1 }))
    // animationToggle === 'off' && pritingApi.start(i => ({ opacity: 0 }))
    // initCanvas()
  }, [animationToggle])

  return (
    <div>
      <div className={styles.printing}>
        {
          printingSprings.map((item, index) => {
            return <animated.span className={styles.singleWord} style={item} >{testString[index]}</animated.span>
          })
        }
      </div>
      <div className={styles.placeholder}>
        {
          testString.map((item, index) => {
            return <span className={styles.singleWord}>{item}</span>
          })
        }
      </div>
    </div>
  )
}

export default PhotoWall

稍微规整一下代码,添加一些细节控制,如主标题副标题,打印速度,离开视口的逻辑等,完善逻辑后组件代码如下

import React, { ReactNode, useRef, useEffect } from 'react'
import { useSpring, animated, useSprings } from '@react-spring/web'
import classnames from 'classnames'
import { useInViewport } from 'ahooks'

import useScrollView from '@/hooks/useScrollView'

import styles from './index.module.less'

/*
0 % { transform: scale(1); }
25 % { transform: scale(.97); }
35 % { transform: scale(.9); }
45 % { transform: scale(1.1); }
55 % { transform: scale(.9); }
65 % { transform: scale(1.1); }
75 % { transform: scale(1.03); }
100 % { transform: scale(1); }
` */

export interface MovingTitlePropsRule {
  title?: string
  subTitle?: string | string[] | ReactNode
  className?: string
  style?: React.CSSProperties
  checkScroll?: boolean
  shouldMove?: boolean
  delay?: number
  inViewportFunc?: 'useScrollView' | 'useInViewport'
  speed?: number // 标题文字打印速度(每个字母出现时间)
  titleStyle?: any // 主标题附加样式
}

export default function MovingTitle (props: MovingTitlePropsRule) {
  const {
    title = '', shouldMove = true,
    className = '', style, subTitle = '',
    delay = 0,
    inViewportFunc = 'useScrollView',
    speed = 60,
    titleStyle
  } = props

  const [printingSprings, pritingApi] = useSprings(title.split('').length, i => ({
    from: { opacity: 0 },
    delay: i * speed + delay,
    config: { duration: 200 }
  }))

  const [subTitleStyles, subTitleApi] = useSpring(() => ({
    opacity: 0,
    translateY: 36
  }))

  const containerRef = useRef<HTMLDivElement>(null)
  const startAnimation = () => {
    subTitleApi.start(i => ({
      opacity: 1,
      translateY: 0,
      config: { tension: 300, friction: 30 },
      delay: title.split('').length * speed + delay
    }))
    pritingApi.start(i => ({ delay: i * speed + delay, opacity: 1 }))
  }

  if (inViewportFunc === 'useScrollView') {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useScrollView(containerRef, (status: boolean) => {
      if (status) {
        startAnimation()
      }
    }, 0.2)
  }

  if (inViewportFunc === 'useInViewport') {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const [inViewPort] = useInViewport(containerRef)
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (inViewPort) {
        startAnimation()
      }
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [inViewPort])
  }

  return (
    <div ref={containerRef} className={`${styles.container} ${shouldMove ? styles.moveContainer : ''} ${className} text-display`}
      style={style}>
      <div className={classnames(styles.printingContainer, 'text-display-title-container')}>
        <div className={styles.printing}>
          {
            printingSprings.map((item, index) => {
              return <animated.span key={index} className={styles.singleWord} style={titleStyle ? { ...item, ...titleStyle } : item} >{title.split('')[index]}</animated.span>
            })
          }
        </div>
        {/* placeholder用于占位提前设置好宽高 */}
        <div className={styles.placeholder}>
          {
            title.split('').map((item, index) => {
              return <span key={index} className={styles.singleWord}>{item}</span>
            })
          }
        </div>
      </div>

      <animated.div
        className={classnames(styles.subTitle, 'text-display-sub-title-container')}
        style={{ ...subTitleStyles }}
      >
        {Array.isArray(subTitle) // 传进来数组需要换行处理
          ? subTitle.map((item, index) => {
            return <div className={classnames(styles.subText, 'text-display-sub-title')} key={index}>
              {item}
            </div>
          })
          : <div className={classnames(styles.subText, 'text-display-sub-title')}>{subTitle}</div>
        }
      </animated.div>
    </div>
  )
}

less文件代码

.container {

  .trailsText {
    position: relative;
    width: 100%;
    color: black;
    font-size: 48px;
    font-weight: 400;
    will-change: transform, opacity;
    line-height: 55px;
    white-space: nowrap;
    overflow: hidden;
    font-family: BasisGrotesque-Regular, BasisGrotesque;

  }

  .trailsText > div {
    padding-right: 0.05em;
    overflow: hidden;
  }

  .titleAnimation {
    .titleContainer {
      .title {
        will-change: translateY;

        .subText {
        }
      }
    }
  }

  .subTitleAnimation {
    width: 400px;

    .subTitleContainer {
      .subTitle {
        .subText {
        }
      }
    }
  }
}


.moveContainer {
  animation: rotatefresh 6s infinite alternate linear;
  display: inline-block;

  @keyframes rotatefresh {
    0% {
      transform: translate3d(0, 0, 0)
    }

    40% {
      transform: translate3d(10px, 5px, 0);
      transition: all 1s;
    }

    60% {
      transform: translate3d(5px, 10px, 0);
      transition: all 1s;
    }

    100% {
      transform: translate3d(0, 0, 0);
      transition: all 1s;
    }
  }
  @media screen and (max-width: 960px) {
    animation: none;
  }
}


.printingContainer {
  position: relative;
  font-family: BasisGrotesque-Regular, BasisGrotesque;
  font-weight: 400;
  color: @primary-color;
  font-size: 48px;
  line-height: 56px;

  .printing {
    position: absolute;
    left: 0;
    top: 0;
  }

  .placeholder {
    visibility: hidden;
  }
}

:global {
  .text-display-sub-title-container {
    margin-top: 24px;
  }
}

Kapture 2023-02-21 at 20.47.45.gif 可见还是能达到应有的效果,但节点数却多了一倍。 关于避开拆分,在设计要求较高的情况下,效果确实达不到要求。 后面因为设计方面考虑更换其它效果,这种类型的标题组件就去除了,其实单纯的打印效果,可以通过css来实现,具体的代码如下
.typing {
    color: white;
    font-size: 2em;
    width: 21em;
    height: 1.5em;
    border-right: 1px solid transparent;
    animation: typing 2s steps(42, end), blink-caret .75s step-end infinite;
    font-family: Consolas, Monaco;
    word-break: break-all;
    overflow: hidden;
}
/* 打印效果 */
@keyframes typing {
    from {
        width: 0;
    }

    to {
        width: 21em;
    }
}

/* 光标 */
@keyframes blink-caret {

    from,
    to {
        border-color: transparent;
    }

    50% {
        border-color: currentColor;
    }
}
<html>
    <head>
        <link rel="stylesheet" href="./css/typing-style.css">
    </head>
    <body>
        <div class="typing">哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈</div>
</html>
  1. 标题动画反转

反转效果的使用场景比较少,一般用于节点离开视口的时候反转回初始状态。

Kapture 2023-02-21 at 21.33.59.gif 一般采用插件相应的reverse api就能实现,基本逻辑就是将节点实例化的对象用ref存储起来,再通过调用相应api实现效果控制。

二、标题动画效果的功能延伸

上文所述基本版本使用的是react-spring对各个元素节点进行控制,所使用的react-spring相关控件有a, useTransition, useSpringRef和useSpring,再搭配上一些元素相关的判断hook逻辑,显得代码稍显繁琐,而且也不利于做更细节的控制,毕竟react-spring无法限制动画时间,再加上react-spring在组件多次重渲染的时候会多次执行动画,导致在页面出现次数较多的标题组件显得有点杂乱,影响整体效果,所以尝试用GSAP对react-spring进行替换,看能否达到更理想的效果,相关代码如下

import React, {
  CSSProperties,
  ReactNode,
  Children,
  useContext,
  useRef,
  useEffect
} from 'react'
import classnames from 'classnames'
import { Tween, PlayState } from 'react-gsap'
import { useInViewport } from 'ahooks'

import XIcon from '@/components/xIcon'
import ConfigContext from '@/components/layout/configContext'

import useStoreContext, { CLIENT_TYPE } from '@/hooks/useStoreContext'

import { delayFunc } from '@/utils'

import PageStyles from './index.module.less'

export interface AnimationTitleProps {
  className?: string
  title?: ReactNode
  subTitle?: ReactNode
  closeIcon?: ReactNode
  delay?: number
  style?: CSSProperties
  animation?: boolean
  isOnce?: boolean
  titleStyle?: CSSProperties
  closeIconStyle?: CSSProperties
  reverseAnimationRef?: any
  startAnimationRef?: any
}

export const defaultCloseIcon = (<XIcon
  name='x-new'
/>)

// TODO 移动端关闭动效

const AnimationTitle = (props: AnimationTitleProps) => {
  const {
    className,
    title,
    subTitle,
    closeIcon = defaultCloseIcon,
    delay = 300,
    style,
    animation = true,
    isOnce = false,
    closeIconStyle,
    titleStyle,
    reverseAnimationRef = null,
    startAnimationRef = null
  } = props

  type GSAP = () => {
    play: () => void
    reverse: () => void
  }

  const { isRunMultiTime } = useContext(ConfigContext)
  const closeIconRef = useRef<React.MutableRefObject<any> & { getGSAP: GSAP }>(null)
  const titleRef = useRef<React.MutableRefObject<any> & { getGSAP: GSAP }>(null)
  const subTitleRef = useRef<React.MutableRefObject<any> & { getGSAP: GSAP }>(null)

  const domRef = useRef<HTMLDivElement>(null)
  const [inViewPort] = useInViewport(domRef)

  const { state } = useStoreContext()

  useEffect(() => {
    console.log(inViewPort, isOnce, isRunMultiTime,'inViewPort&&isOnce');

    if (!animation || state?.clientType === CLIENT_TYPE.H5) {
      return
    }
    const startAnimation = async () => {
      await delayFunc(delay)
      if (closeIcon) {
        closeIconRef.current?.getGSAP().play()
      }
      await delayFunc(200)
      if (title) {
        titleRef.current?.getGSAP().play()
      }
      // 等 title 动画的一半就开始运行
      await delayFunc(500)
      if (subTitle) {
        await subTitleRef.current?.getGSAP().play()
      }
    }

    const reverseAnimation = () => {
      [closeIconRef, titleRef, subTitleRef].forEach((item) => {
        item.current?.getGSAP().reverse()
      })
    }

    if (reverseAnimationRef) {
      reverseAnimationRef.current = reverseAnimation
    }

    if (startAnimationRef) {
      startAnimationRef.current = startAnimation
    }

    if (inViewPort) {
      startAnimation()
    } else if (isRunMultiTime && !isOnce) {
      reverseAnimation()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [inViewPort, isOnce])

  let playState = animation ? PlayState.stop : PlayState.stopEnd

  // animation 未定义且是 h5 的情况下,去掉动效
  if (state?.clientType === CLIENT_TYPE.H5) {
    playState = PlayState.stopEnd
  }

  return (
    <div
      className={classnames(
        PageStyles.textDisplay,
        'text-display',
        className
      )}
      style={style}
      ref={domRef}
    >
      {
        closeIcon && <Tween
          ref={closeIconRef as React.MutableRefObject<any>}
          from={{
            opacity: 0,
            rotate: -100
          }}
          to={{
            opacity: 1,
            rotate: 0
          }}
          playState={playState}
        >
          <div
            className={classnames(PageStyles.closeIcon, 'close-icon')}
            style={closeIconStyle}
          >
            {closeIcon}
          </div>
        </Tween>
      }
      {
        title && <Tween
          ref={titleRef as React.MutableRefObject<any>}
          from={{
            scale: 0,
            opacity: 0
          }}
          to={{
            opacity: 1,
            scale: 1
          }}
          playState={playState}
        >
          <div
            className={classnames(
              PageStyles.title,
              'text-display-title'
            )}
            style={titleStyle}
          >
            {title}
          </div>
        </Tween>
      }
      {
        subTitle && <div
          className={classnames(
            PageStyles.subTitleContainer,
            'text-display-sub-title-container'
          )}
        >
          <Tween
            ref={subTitleRef as React.MutableRefObject<any>}
            from={{
              opacity: 0,
              y: 50
            }}
            to={{
              opacity: 1,
              y: 0
            }}
            stagger={0.6}
            duration={Children.toArray(subTitle).length * 0.4}
            playState={playState}
          >
            {
              Children.toArray(subTitle).map((item: any, index: number) => {
                return (
                  <div
                    className={classnames(
                      PageStyles.subTitle,
                      'text-display-sub-title'
                    )}
                    key={index}
                  >
                    {item}
                  </div>
                )
              })
            }
          </Tween>
        </div>
      }
    </div>
  )
}

export default AnimationTitle

可见在使用了react-gsap后组件代码结构显然更清晰了,并且达成了所需要的效果,最重要的是避免了动画的频繁抖动,至此标题组件达到了一个相对稳定的状态,下面展示下标题组件在各种情况下搭配别的设计元素的展示效果。

Kapture 2023-02-22 at 19.44.58.gif

最终站点: heyxpeng.com/