复杂 web 动画开发

复杂 web 动画开发

为什么 选择 GSAP

  • GSAP 是一个老牌框架,但也在持续更新,兼容性很好

  • 与框架无关,比如 framer-motion,react spring 只能用于 react;这里有多个框架的 starter

  • 心智负担低,易于上手,它的核心理念就是以时间轴(Timeline)为维度的补间动画(Tween),在文章后面会聊一下,然后还有各种强大的插件,如 scrollTrigger

  • 经过时间的沉淀,在 react 中使用提供了简便的 react api,比如自带动画清理的useGSAP

补间动画(Tween)

补间是所有动画的工作 - 可以把它想象成一个高性能的属性设置器。输入目标(要进行动画处理的对象)、持续时间以及希望其动画化的任何属性,然后当补间的播放头移动到新位置时,确定此时属性值应是什么,相应地应用它们

常用创建补间动画的方法

js 复制代码
gsap.to()
gsap.from()
gsap.fromTo()

用法很简单,就不细说了,如下 demo:

js 复制代码
gsap.to('.box', { rotation: 27, x: 100, duration: 1 })

时间线(Timeline)

让补间动画可以排序,可让动画及时放置在任何想要的位置,然后使用 pause()、play()、progress()、reverse()、timeScale() 等方法轻松控制整个序列

text 复制代码
                        PLAYHEAD
|--------------timeline-----|-----------|
|--tween1--|                |
           |-----tween2-----|-----------|

可以用 addLabel 标记时间线的点,通过第二个参数来做对应动画

js 复制代码
tl.addLabel("step2", 3)
  .to(..., "step2")
  .to(..., "step2+=0.75")

控制补间和时间线

js 复制代码
var tween = gsap.to(...);
var tl = gsap.timeline();
tl.to(...).to(...);

tween.pause();
tween.timeScale(2);
tl.seek(3);
tl.progress(0.5);
...

动画曲线

可视化动画曲线 gsap.com/docs/v3/Eas...

封装渐入效果

jsx 复制代码
import { gsap } from 'gsap/gsap-core'
import { useGSAP } from '@gsap/react'

export const useFadeIn = ({
  target,
  item
}: {
  target: string,
  item: string
}) => {
  useGSAP(() => {
    gsap
      .timeline({
        scrollTrigger: {
          trigger: target,
          start: '0% 90%',
          end: '0% 90%',
          toggleActions: 'play none reverse none'
        }
      })

      .from(item, {
        y: 200,
        opacity: 0
      })
  })
}

跟随鼠标滚动动画(1)

js 复制代码
import { gsap } from 'gsap/gsap-core'
import { useGSAP } from '@gsap/react'

useGSAP(() => {
  const t1 = gsap
    .timeline({
      scrollTrigger: {
        trigger: '.section9-item1',
        start: '0% 70%',
        end: '0% 70%',
        toggleActions: 'play none reverse none'
      }
    })
    .addLabel('spin')

  t1.from(
    '.section9-item1',
    {
      x: '-10%',
      opacity: 0
    },
    'spin'
  )

  t1.from(
    '.section9-item2',
    {
      x: '10%',
      opacity: 0
    },
    'spin'
  )

  const t2 = gsap
    .timeline({
      scrollTrigger: {
        trigger: '.section9-item3',
        start: '0% 70%',
        end: '0% 70%',
        toggleActions: 'play none reverse none'
      }
    })
    .addLabel('spin')

  t2.from(
    '.section9-item3',
    {
      x: '-10%',
      opacity: 0
    },
    'spin'
  )

  t2.from(
    '.section9-item4',
    {
      x: '10%',
      opacity: 0
    },
    'spin'
  )
})

跟随鼠标滚动动画(2)

js 复制代码
import { gsap } from 'gsap/gsap-core'
import { useGSAP } from '@gsap/react'

useGSAP(() => {
  gsap
    .timeline({
      scrollTrigger: {
        trigger: section2Ref.current,
        // pin: true,
        start: 'top 90%',
        end: '+=1000',
        scrub: 1,
        toggleActions: 'play none reverse none'
      }
    })

    .to('.section2_line', {
      stagger: 0.1,
      y: -40,
      keyframes: {
        '0%': { color: '#4c4c4c' },
        '25%': { color: '#4c4c4c' },
        '50%': { color: '#ffffff' },
        '75%': { color: '#4c4c4c' },
        '100%': { color: '#4c4c4c' }
      }
    })
})

GSAP pin 效果(1)

jsx 复制代码
import { gsap } from 'gsap/gsap-core'
import { useGSAP } from '@gsap/react'

useGSAP(() => {
  const tl = gsap.timeline({
    scrollTrigger: {
      trigger: '.section5',
      start: 'center center',
      end: '+=2000',
      scrub: 1,
      pin: true,
      toggleActions: 'play none reverse none'
    }
  })

  tl.to('.section5_block', {
    right: 0
  })
})

return (
  <section className="section5 flex h-screen">
    <div className="relative m-auto h-[650px] w-[1280px]">
      <div className="section5_block absolute flex w-fit text-center">
        {node.map((item, index) => {
          return (
            <div
              className="mr-[36px] flex h-[650px] w-[1280px] flex-col items-center justify-center bg-[#1c1c1c] last:mr-[36px]"
              key={index}
            >
              <div className="base_title1">{item.title}</div>
              <div className="base_desc">{trans(item.desc)}</div>
            </div>
          )
        })}
      </div>
    </div>
  </section>
)

GSAP pin 效果(2)

jsx 复制代码
import { gsap } from 'gsap/gsap-core'
import { useGSAP } from '@gsap/react'

useGSAP(() => {
  const t2 = gsap
    .timeline({
      scrollTrigger: {
        trigger: '.section4-block',
        start: '50% 50%',
        end: '+=4000',
        toggleActions: 'play none reverse none',
        pin: true,
        scrub: 1
      }
    })
    .addLabel('spin')

  t2.from(
    '.section4-video ',
    {
      width: '300px',
      height: '300px',
      left: 0,
      top: 100
    },
    'spin'
  )

  t2.from(
    '.section4-img',
    {
      opacity: 0
    },
    'spin'
  )

  t2.from('.bubble1', {
    x: '10%',
    opacity: 0
  })

  t2.from('.bubble2', {
    x: '10%',
    opacity: 0
  })

  t2.from('.bubble3', {
    x: '10%',
    opacity: 0
  })

  t2.from('.bubble4', {
    x: '-10%',
    opacity: 0
  })

  t2.from('.bubble5', {
    x: '-10%',
    opacity: 0
  })

  t2.from('.bubble6', {
    x: '-10%',
    opacity: 0
  })
})

GSAP pin 效果(3)

js 复制代码
useGSAP(() => {
  const t1 = gsap.timeline({
    scrollTrigger: {
      trigger: '.section14',
      start: 'center center',
      end: '+=1000',
      scrub: 1,
      toggleActions: 'play none reverse none',
      pin: true
    }
  })
  t1.to('.section14-block', {
    clipPath: 'inset(30% 30%)'
  })
})

Framer Motion 实现(3)效果

虽然 Framer Motion 写复杂有点有点复杂,但一些简单动画还是很香的 😊

js 复制代码
import Image from 'next/image'
import { motion, useScroll, useTransform } from 'framer-motion'
import { useLayoutEffect, useRef, useState } from 'react'
import type { Block1218 } from '../../type'

export default function Section24(props: { data: Block1218 }) {
  const { name, bg } = props.data

  const targetRef = (useRef < HTMLDivElement) | (null > null)

  const { scrollYProgress } = useScroll({
    target: targetRef,
    offset: ['start start', 'end end']
  })

  const [target, setTarget] = useState('inset(0px 0px)')

  useLayoutEffect(() => {
    if (window.innerWidth && window.innerWidth < 1480) {
      setTarget(`inset(${240}px ${380}px)`)
      return
    }
    setTarget(`inset(${450}px ${890}px)`)

    const resizeHandle = () => {
      if (window.innerWidth && window.innerWidth < 1480) {
        setTarget(`inset(${250}px ${446}px)`)
        return
      }
      setTarget(`inset(${450}px ${890}px)`)
    }

    window.addEventListener('resize', resizeHandle)

    return () => {
      window.removeEventListener('resize', resizeHandle)
    }
  }, [])

  const inset = useTransform(
    scrollYProgress,
    [0, 1],
    [target, 'inset(0px 0px)']
  )

  const color = useTransform(scrollYProgress, [0, 1], ['#000000', '#ffffff'])

  const position = useTransform(scrollYProgress, (pos) => {
    if (pos === 0) return 'relative'
    return pos >= 1 ? 'relative' : 'fixed'
  })

  // pin完之后transition到最后
  const y = useTransform(scrollYProgress, (pos) => {
    if (pos === 1) return '2000px'
  })

  return (
    <motion.section
      className="section24 relative h-[calc(100vh_+_2000px)] min-h-[1000px] pb-[2000px]"
      ref={targetRef}
    >
      <motion.div
        className="relative left-0 top-0 flex h-screen w-screen bg-white"
        style={{ position, y }}
      >
        <motion.div
          className="absolute left-[50%] top-[6vw] z-[2] mb-[84px] translate-x-[-50%] text-center text-[42px] font-bold leading-[1.4] text-black"
          style={{ color }}
        >
          {name.split('$').map((item, index) => {
            return (
              <p className="whitespace-nowrap" key={index}>
                {item}
              </p>
            )
          })}
        </motion.div>
        <motion.div
          className="m-auto h-full w-full overflow-hidden"
          style={{ clipPath: inset }}
        >
          <Image
            alt=""
            className="h-full w-full object-cover"
            height={5120}
            src={bg}
            width={5120}
          ></Image>
        </motion.div>
      </motion.div>
    </motion.section>
  )
}

浅谈原理

css 可以做简单动画,但复杂 web 动画还是需要 js,gsap 的核心原理也就是requestAnimation做js动画

js 复制代码
function myAnimation(element, options) {
  const { rotation, x, duration } = options
  const startRotation = element.style.transform || 'rotate(0deg)'
  const startX = parseFloat(element.style.left) || 0

  const startTime = performance.now()

  function animate(currentTime) {
    const elapsed = (currentTime - startTime) / 1000
    const progress = Math.min(elapsed / duration, 1)

    const currentRotation = startRotation + rotation * progress
    const currentX = startX + x * progress

    element.style.transform = `rotate(${currentRotation}deg)`
    element.style.left = `${currentX}px`

    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  requestAnimationFrame(animate)
}

这样就实现了一个和 gsap.to 的效果

js 复制代码
const element = document.querySelector('.box')
myAnimation(element, { rotation: 27, x: 100, duration: 1000 })
相关推荐
卸任2 小时前
Vibe coding工具使用心得
前端·ai编程
brien93 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance3 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M3 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写3 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易4 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马4 小时前
前沿视界
前端
kyriewen4 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易4 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员