React + GSAP + ScrollTrigger + Vite ==> 炫酷3D动画网页 -- 中篇

该项目由React + vite 搭建 基于GSAP和ScrollTrigger实现炫酷3D动画网页

动画效果可以看gif图或者查看 线上地址仓库

本期接着 上篇 继续实现炫酷3D动画网页

这篇文章主要讲解以下部分

  • 安装 介绍 使用依赖库 GSAP ScrollTrigger lenis
  • code可复用hooks initSmoothScrolling.js useImagePreloader.js
  • 响应式设计 chooseAnimation

安装依赖

GSAP + ScrollTrigger

  • GSAP(GreenSock Animation Platform)是Web动画开发的工具库,可使CSS 属性、SVG、React、画布、通用对象等动画化,并解决不同浏览器上存在的兼容问题

  • ScrollTrigger 是 GSAP 的插件,ScrollTrigger控制滚动动画,而真正处理动画是GSAP,二者组合使用才能实现丝滑滚动动画

  • 安装 使用

zsh 复制代码
npm i -D gsap
jsx 复制代码
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

lenis库介绍

  • lenis也是动画库 可以实现惯性滚动 给鼠标滚轮增加阻尼感 纵享丝滑
  • 查看 lenis官方效果

当我们在移动终端上滑动页面,手指离开屏幕后,页面的滚动并不会马上停止,而是在一段时间内继续保持惯性滚动,并且滑动阻尼感和持续时间与滑动手势的幅度成正比

zsh 复制代码
npm i @studio-freight/lenis

简单实例

jsx 复制代码
import Lenis from '@studio-freight/lenis'

const lenis = new Lenis()
lenis.on('scroll', (e) => {
  console.log(e)
})
function raf(time) {
  lenis.raf(time)
  requestAnimationFrame(raf)
}
requestAnimationFrame(raf)

封装hooks !!

lenis + ScrollTrigger 初始化丝滑滚动效果

  • 创建 Lenis实例
    • 配置平滑滚动的速度 值越小平滑效果越明显lerp: 0.1
    • 启用了鼠标滚轮事件的平滑滚动 smoothWheel: true
  • 用户滚动时更新 ScrollTrigger
    • lenis.on 监听 'scroll' 事件
    • 用户滚动页面时更新 ScrollTrigger
  • 定义动画帧函数
    • crollFn在每一帧动画执行时被调用
    • 通过调用 Lenis 实例的 raf 方法来实现平滑滚动
    • requestAnimationFrame 来实现递归调用
  • 启用动画帧更新
    • 调用requestAnimationFrame(scrollFn) 监听和执行 scrollFn 函数,以维护平滑滚动效果
js 复制代码
//  utils/initSmoothScrolling.js
import Lenis from '@studio-freight/lenis'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

export const initSmoothScrolling = () => {
	const lenis = new Lenis({
		lerp: 0.1,
		smoothWheel: true
	})
	lenis.on('scroll', () => ScrollTrigger.update())
	const scrollFn = time => {
		lenis.raf(time)
		requestAnimationFrame(scrollFn)
	}
	requestAnimationFrame(scrollFn)
}

预加载图片 useImagePreloader

  • 导入图片: 图片都在src/img目录之下 由于vite不支持require 我们可以通过import.meta.globEager方法获取所有以'.jpg'结尾的图片模块
  • 创建imagePromises[] 容纳导出image promise对象,每个image创建了一个 Image 对象,设src属性为module.default
  • 使用 Promise.all 等待所有图片加载完成 然后储存在images中 return出去
jsx 复制代码
// utils/useImagePreloader.js
import { useEffect, useState } from 'react'

export const useImagePreloader = () => {
	const [images, setImages] = useState([])
	useEffect(() => {
		async function preloadImages() {
			const imageModules = import.meta.globEager('../img/*.jpg')
			const imagePromises = Object.values(imageModules).map(module => {
				const image = new Image()
				image.src = module.default
				return image
			})

			const loadedImages = await Promise.all(imagePromises)
			setImages(loadedImages)
		}
		preloadImages()
	}, [])
	return images
}

结合上期文章说的检测浏览器是否支持css 函数supportsCssVars 可以将代码组织如下

  • useLayoutEffect在浏览器绘制 渲染真正dom之前初始化initSmoothScrolling
  • 之后还会将选择动画chooseAnimation 在其中组织
jsx 复制代码
// src/GridAnimation.jsx
import { useLayoutEffect, useState } from 'react'
import { initSmoothScrolling, useImagePreloader, supportsCssVars } from './utils'
function GridAnimation() {
	const [loading, setLoading] = useState(true)
	const images = useImagePreloader()
	useLayoutEffect(() => {
		supportsCssVars() || alert('请在支持CSS变量的现代浏览器中查看此演示')
        initSmoothScrolling()
        // chooseAnimation(gridType)
        setLoading(false)
	}, [images])
	if (loading || !images.length) return <div className="loading"></div>
	return (
		<div>
			<main>
				<div className="intro">
					<h1 className="intro__title">
						<span className="intro__title-pre">On-Scroll</span>
						<span className="intro__title-sub">Perspective Grid Animations</span>
					</h1>
					<span className="intro__info">Scroll moderately to fully experience the animations</span>
				</div>
			</main>
		</div>
	)
}
export default GridAnimation

可以看到图片也已经加载好了 差不多 ! 前期准备工作基本完成 进入正题!

进入正题 ! Animation!

整体布局优化

我们整体的结构应该是这样的

  • 先关注其中一个section结构 其他的循环出来就好了
  • 其中要注意grid-wrap之下里面套了一层循环渲染image 因为很大所有没有展开 具体可以看下面的结构
html 复制代码
<div>
    <main>
        <div className="intro">...</div>
        <section key={index} className='content'>
            <div className={'grid'}>
                <div className="grid-wrap">
                    {images.map((item, index) => (
                        <div className="grid__item" key={index}>
                            <img className="grid__item-inner" src={item.src} />
                        </div>
                    ))}
                </div>
            </div>
            <h3 className='content__title'>{children}</h3>
        </section>
        <section>...<section/>
    </main>
</div>

美化样式

  • 采用BEM命名规范
    • 中划线(-) :仅作为连字符使用,表示某个块或者某个子元素的多单词之间的连接记号。
    • 双下划线(__):双下划线用来连接块和块的子元素
    • 单下划线(_):单下划线用来描述一个块或者块的子元素的一种状态
  • 采用var自定义属性增强复用性 动态可扩展
  • vh(视窗高度单位) rem(根元素字体大小单位)clamp(min, preferred, max)实现响应式设计
css 复制代码
.content {
	position: relative;
	margin-bottom: 20vh;
}

.grid {
	display: grid;
	place-items: center;
	padding: 2rem;
	width: 100%;
	perspective: var(--perspective);
}

.grid-wrap {
	height: var(--grid-height);
	width: var(--grid-width);
	display: grid;
	grid-template-columns: repeat(var(--grid-columns), 1fr);
	gap: var(--grid-gap);
	transform-style: preserve-3d;
}

.grid__item {
	aspect-ratio: var(--grid-item-ratio);
	width: 100%;
	height: auto;
	overflow: hidden;
	position: relative;
	border-radius: 8px;
	display: grid;
	place-items: center;
}

.grid__item-inner {
	position: relative;
	width: calc(1 / var(--grid-inner-scale) * 100%);
	height: calc(1 / var(--grid-inner-scale) * 100%);
	background-size: cover;
	background-position: 50% 50%;
}
.content__title {
	position: absolute;
	height: 100vh;
	width: 100vw;
	top: 50%;
	left: 50%;
	margin: -50vh 0 0 -50vw;
	padding: 0 10vw;
	display: grid;
	place-items: center;
	text-align: center;
	font-weight: 300;
	font-size: clamp(1.5rem, 15vw, 6.5rem);
}

效果

现在差不多能够看了 但效果只能说四平八稳 没什么特点 上Animation!

chooseAnimation 响应式设计

getGrid函数 -- 操作奇偶数行grid

是由GreenSock官方 提供的可以动态地计算网格的行/列 操作奇数或偶数行设置动画 当库函数直接拿过来用就行

js 复制代码
const getGrid = selector => {
	let elements = gsap.utils.toArray(selector),
		bounds,
		getSubset = (axis, dimension, alternating, merge) => {
			let a = [],
				subsets = {},
				onlyEven = alternating === 'even',
				p
			bounds.forEach((b, i) => {
				let position = Math.round(b[axis] + b[dimension] / 2),
					subset = subsets[position]
				subset || (subsets[position] = subset = [])
				subset.push(elements[i])
			})
			for (p in subsets) {
				a.push(subsets[p])
			}
			if (onlyEven || alternating === 'odd') {
				a = a.filter((el, i) => !(i % 2) === onlyEven)
			}
			if (merge) {
				let a2 = []
				a.forEach(subset => a2.push(...subset))
				return a2
			}
			return a
		}
	elements.refresh = () => (bounds = elements.map(el => el.getBoundingClientRect()))
	elements.columns = (alternating, merge) => getSubset('left', 'width', alternating, merge)
	elements.rows = (alternating, merge) => getSubset('top', 'height', alternating, merge)
	elements.refresh()
	return elements
}

chooseAnimation

列举其中一个AnimationType grid--1来举例子 其他的type可以看源码

js 复制代码
export const chooseAnimation = (animationType, grid) => {
	const gridWrap = grid.querySelector('.grid-wrap')
	const gridItems = grid.querySelectorAll('.grid__item')
	const gridItemsInner = [...gridItems].map(item => item.querySelector('.grid__item-inner'))
	const timeline = gsap.timeline({
		defaults: { ease: 'none' }, 
		scrollTrigger: {
			trigger: gridWrap, 
			start: 'top bottom+=5%',
			end: 'bottom top-=5%',
			scrub: true,
		},
	})
	switch (animationType) {
		case 'grid--1':
			grid.style.setProperty('--perspective', '1000px')
			grid.style.setProperty('--grid-inner-scale', '0.5')
			timeline
                .set(gridWrap, { rotationY: 25 })
                .set(gridItems, { z: () => gsap.utils.random(-1600, 200) })
                .fromTo(gridItems, { xPercent: () => gsap.utils.random(-1000, -500) }, { xPercent: () => gsap.utils.random(500, 1000) }, 0)
                .fromTo(gridItemsInner, { scale: 2 }, { scale: 0.5 }, 0)
			break
        case 'grid--2':
        ... 
        break
        ...
		default:
			console.error('未知animation type')
			break
    }
}

函数详细注释

  • chooseAnimation接收俩个参数 animationType(动画类型的名称) 和 grid(包含网格元素的DOM元素)

  • 获取 操作dom元素

    • 在grid元素之下查找grid-wrap和gridItem
    • 将gridItems 转换为数组,遍历查找每个子元素中具有类名'grid__item-inner' 的元素储存在gridItemsInner中
  • 创建gsap(GreenSock Animation Platform)的时间线(timeline)

    • 在时间线配置对象中,设置了默认的缓动函数为 'none'
    • 配置scrollTrigger 指定gridWrap 元素作为滚动触发器的触发器
    • start: 'top bottom+=5%'触发器的开始位置被设置在 gridWrap 的顶部,然后再下移5%的距离触发动画
    • end: 'bottom top-=5%'触发器的结束位置被设置在 gridWrap 的底部,然后再上移5%的距离触发动画。
    • scrub: true动画会根据滚动位置进行缓慢播放
  • 根据传入的 animationType选择不同的动画类型

    • 对每个grid重载不同css自定义属性--perspective--grid-inner-scale等等
  • 具体化时间线设置

    • timeline.set方法将 gridWrap 元素的 rotationY 属性设置为 25timeline.set(gridWrap, { rotationY: 25 })
    • 使用随机函数设置 gridItems 元素的 z 属性,范围在 -1600 到 200 之间timeline.set(gridItems, { z: () => gsap.utils.random(-1600, 200) })
    • 使用 fromTo 方法来设置 gridItems 元素的 xPercent 属性,从一个随机值过渡到另一个随机值。 0 表示从时间线的开始位置执行这个动画
    • 设置gridItemsInner 元素的 scale 属性,从 2倍缩小到 0.5倍

chooseAnimation使用

jsx 复制代码
useLayoutEffect(() => {
    const grids = document.querySelectorAll('.grid')
    const promises = Array.from(grids).map((grid, i) => chooseAnimation(`grid--${i + 1}`, grid))
    await Promise.all(promises)
}

那么现在就可以重新组织一下代码啦

jsx 复制代码
import { useLayoutEffect, useState } from 'react'
import { initSmoothScrolling, useImagePreloader, supportsCssVars, chooseAnimation } from './utils'
import './style/index.less'
function GridAnimation() {
	const [loading, setLoading] = useState(true)
	const images = useImagePreloader()
	useLayoutEffect(() => {
		const animateGrids = async () => {
			supportsCssVars() || alert('请在支持CSS变量的现代浏览器中查看此演示')
			initSmoothScrolling()
			const grids = document.querySelectorAll('.grid')
			const promises = Array.from(grids).map((grid, i) => chooseAnimation(`grid--${i + 1}`, grid))
			await Promise.all(promises)
			setLoading(false)
		}
		animateGrids()
	}, [images])
	if (loading || !images.length) return <div className="loading"></div>
	return (
		<div>
			<main>
				<div className="intro">
					<h1 className="intro__title">
						<span className="intro__title-pre">On-Scroll</span>
						<span className="intro__title-sub">Perspective Grid Animations</span>
					</h1>
					<span className="intro__info">Scroll moderately to fully experience the animations</span>
				</div>
				<section className={'content'}>
					<div className={'grid'}>
						<div className="grid-wrap">
							{images.map((item, index) => (
								<div className="grid__item" key={index}>
									<img className="grid__item-inner" src={item.src} />
								</div>
							))}
						</div>
					</div>
					<h3 className={'content__title'}>
						{'Fleeting moments,'} <br />
						{`existence's dance.`}
					</h3>
				</section>
				<section>...</section>
				<section>...</section>
				<section>...</section>
			</main>
		</div>
	)
}
export default GridAnimation

如何优化代码结构 ?

假设我们有很多个AnimationType 而每个section结构都高度耦合 同时还会设置不同的css属性 不可能直接就粘贴复制改改className刷刷刷完成任务吧。。虽然这个小项目没啥人看 但也还是坚守职业修为!! 所以!!必须做一些抽取封装!那么下一期就是重新组织代码结构 + 部署上线咯

今天的任务就圆满结束啦 有收获可以点赞收藏哦~

推荐文章

参考

相关推荐
zhougl9961 小时前
html处理Base文件流
linux·前端·html
花花鱼1 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
HBR666_1 小时前
marked库(高效将 Markdown 转换为 HTML 的利器)
前端·markdown
careybobo3 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
杉之4 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端4 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡4 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木6 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法
難釋懷7 小时前
JavaScript基础-移动端常见特效
开发语言·前端·javascript