一、标题文字基础动画效果
项目中白体常用于突出页面主题或者展示一些附加信息,基本上标题相关的数据不会一大段的出现,设计上至多分为三层,标题下又时常跟随着一个段落的介绍,所以对于项目全局的动画,一般都要考虑标题入场离场,以及标题动画在某个状态时段落等其他内容块如何展示,所以在项目伊始就应该考虑标题动画的相关控制,包括但不限于开始、暂停、结束、倒退以及对应各种状态的监听。
标题动画技术选型
工具 | 优点 | 缺点 |
---|---|---|
react-spring | 弹性动画模型,可以做到很逼真的弹性物理效果,在react适配也很完善,参考网址www.react-spring.dev/ | 难以控制动画的各个节点,因为基于弹性物理公式,在限制动画时长等方面均不是很方便,需要依据公式做相关计算 |
GSAP | 自由度更高,示例多,利用搭配别的工具做更复杂的效果,参考网址:greensock.com/get-started | api老旧,使用不方便 |
animation.css | 是用原生写法,没有太多的封装,包体积很小,参考网址animate.style | 书写代码过于繁琐,需要开发者手动进行封装,同时需要写js和css,还要考虑各种细节交互 |
react-gsap | GSAP的react封装版本,继承了GSAP的优点,同时兼容react的写法,参考网址bitworking.github.io/react-gsap | 对GSAP的功能继承有限,部分效果没法实现 |
个人摸索过程中,前期主要使用react-spring,所实现的相关功能如下
-
时间序列动画
关于时间序列动画,主要是约束元素保持既定的顺序进行展示,如下图所示的导航链式动画效果
应用于标题如下图所示
纯标题展示如下
此阶段还处于标题组件的雏形阶段,后续因功能完善持续改造,陆续添加了各种参数的控制,如使用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;
-
字体打印动画
顾名思义,一个单词一个字母一个字母打印展示的效果
这个效果比较麻烦的是需要让每个字母链式展示,所以必然涉及字符串的拆分,一涉及到字符串拆分,麻烦就多起来了,有以下几点需要考量的细节
- 怎么处理换行?
- 每个字母拆出来必然涉及到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;
}
}
.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>
-
标题动画反转
反转效果的使用场景比较少,一般用于节点离开视口的时候反转回初始状态。
一般采用插件相应的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后组件代码结构显然更清晰了,并且达成了所需要的效果,最重要的是避免了动画的频繁抖动,至此标题组件达到了一个相对稳定的状态,下面展示下标题组件在各种情况下搭配别的设计元素的展示效果。
最终站点: heyxpeng.com/