React.js 网站开发:实现滚动加载动画
极客前端探索者 前沿技术的探索者,编码艺术的实践者最近在开发官网的过程中,涉及到 UI 动画的制作,其中滚动效果的使用比较频繁,特此整理一下,以便查询和温习。
平滑向上过渡动画
这种往下滚动过渡渐变显示的动画是最常见的,比如:向上滚动平滑过渡案例
本文实现demo效果展示:
实现步骤
- 利用
IntersectionObserver
API 来监听各个元素是否进入视口 - 当进入视口时播放进入的过渡动画
- 播放过一次动画后关闭
IntersectionObserver
的监听 - 在刷新页面且保持的滚动状态的时候,只监听当前滚动位置之下的元素
其中 IntersectionObserver
可以这样定义:
const map = new WeakMap();
const ob = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
// 当在视口内的时候,取出当前的元素,播放动画
// 具体怎么记录 map,在下面的实现中会提到
const animation = map.get(entry.target);
if (animation) {
animation.play();
ob.unobserve(entry.target);
}
}
}
});
vue 实现
首先定义一个指令 v-slide
:
const vSlide = {
mounted(el) {
// 判断不是在视口之下的元素,不需要添加动画
if (!isBelowViewport(el)) {
return;
}
const animation = el.animate([
{
transform: `translateY(${SLIDE_FADE_DISTANCE}px)`,
opacity: 0.1
},
{
transform: `translateY(0)`,
opacity: 1
}
], {
duration: SLIDE_FADE_DURATION,
easing: 'ease-in-out',
fill: 'forwards' // 当动画完成后,保留最后一个关键帧的样式
});
animation.pause();
map.set(el, animation);
ob.observe(el);
// return animation;
},
unmounted(el) {
ob.unobserve(el);
}
}
代码解释:利用元素自带的 animate API,给指令挂载的元素添加动画。在一开始全部都暂停,并将当前元素作为唯一key,存入动画对象到 map 中。最后开启 IntersectionObserver
监听。
模板定义如下:
<div v-slide v-for="item in list" class="block" :style="{'background-color': item.bg}">...</div>
源码可参看:门户网站平滑滚动动画-vue
react 实现
实现原理与 vue 相似,只是没有自定义指令可用了,需要封装高阶组件来包裹需要监听的元素,在每一个子块上,使用 ref
来获取 dom 元素,并添加 observer。
源码参考:门户网站平滑滚动动画-react
进阶:复杂滚动动画
上面的加载方式是传统意义的动画,就是以时间为自变量,图形的长宽、颜色、位置等为因变量的一系列状态的变化。在门户网站里还有一种滚动动画,是把自变量改为滚动的位置,而其余的设置不变,动画随滚动位置的不同而不同。
参考案例:daisyui 官网,效果如下:
这种动画随着滚动只播放进度,但定位在屏幕中间一段时间的效果,是通过粘性定位来实现的,看一下原理图:
上图中,蓝色区域就是动画执行的滚动区域。
我们针对这个效果来做简单的实现。
首先来创建元素结构:
// 蓝色区域
<div class="animation-container">
<div class="header">我是顶部内容</div>
// 黄色动画区域
<div class="panel">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
</div>
<div class="other">我是其他区域内容</div>
</div>
加入基础样式后,效果如下:
然后通过 js 函数实现动画。
其实 css 已经支持设置动画方式为滚动了,只是存在兼容性问题,不得已采取这种兼容方式。
通过 js 批量操作 css 变化,最好的方式就是使用 css 变量:
.panel {
--rotate-rate: 1;
--translate-y: 0;
--translate-x: 0;
--opacity: 1;
position: sticky;
top: 30%;
margin: 0 auto;
height: 200px;
width: 250px;
border: 1px solid blue;
box-shadow: 1px 1px 2px #ccc;
transform: rotateX(calc(20deg * var(--rotate-rate))) rotateZ(calc(-20deg * var(--rotate-rate))) skewY(calc(8deg * var(--rotate-rate)));
}
.panel > .item {
color: white;
height: 30px;
width: 30px;
border: 1px dashed red;
background-color: blueviolet;
box-shadow: 4px 4px 6px #ccc;
margin-bottom: 10px;
opacity: var(--opacity);
transform: translate(var(--translate-x), var(--translate-y));
}
这样只需要 js 获取滚动位置,配置位置与各个变量之间的映射,并实时刷新映射,写入变量就可以了。
我们先写一个通用函数,接受滚动的开始、结束位置,开始和结束的值:
function createAnimation(
scrollStart,
scrollEnd,
startValue,
endValue
) {
return function(x) {
// 控制边界
if (x < scrollStart) {
return startValue;
}
if (x > scrollEnd) {
return endValue;
}
// 滚动位置占所有可滚动区域的百分比
const progress = (x - scrollStart) / (scrollEnd - scrollStart);
// 返回当前滚动位置累加出来的值(其实就是百分比计算)
return startValue + (endValue - startValue) * progress;
}
}
这个函数返回的值一定在 startValue 和 endValue 之间,返回高阶函数,接受当前滚动位置,其通过滚动位置均匀地计算中间位置的样式值。我们以透明度为例:
const animationMap = new Map();
const container = document.querySelector('.animation-container');
const items = document.querySelectorAll('.item');
// 初始化执行一遍即可
function updateMap() {
const containerRect = container.getBoundingClientRect();
// 根据滚动容器(蓝色区域)高度计算起始、结束区域
const scrollStart = containerRect.top;
const scrollEnd = containerRect.bottom - window.innerHeight;
for (const item of items) {
animationMap.set(item, getDomAnimation(item, scrollStart, scrollEnd));
}
}
// 返回 css 变量计算好的值
function getDomAnimation(dom, scrollStart, scrollEnd) {
// 这里从不透明变化到 0.2,
const opacityAnimation = createAnimation(scrollStart, scrollEnd, 1, 0.2);
return {
'--opacity': function(x) {
return opacityAnimation(x);
},
}
}
定义一个 map 来维护滚动位置与样式的映射关系,执行 updateMap 函数后,透明度参数就写进 map 里了。
接下来就是将 map 更新进样式里:
// 获取动画区域。写入变量
const panel = document.querySelector('.panel');
function updateStyles() {
const scrollY = window.scrollY; // 如果要计算某个容器内的滚动量,请使用 ele.scrollTop
for (const [dom, animations] of animationMap) {
for (const prop in animations) {
// 每一个 animations 就是上边返回的 function(x){...} 函数,接受参数是当前的滚动位置
// prop 是 getDomAnimation 返回的对象的每一个 key 值,这里是 css 变量标识
panel.style.setProperty(prop, animations[prop](scrollY));
}
}
}
如此,基础的框架就搭好了,补上必须的 css,查看效果:
可以看到,我们示例中配置的透明度也变化了。
全部源码:复杂滚动案例
滚动动画实战:视差滚动动画
视差滚动也是随着滚动位置的不同而改变的动画,他是背景图片位置的变化。
视差滚动指的是块盒在整体滚动的时候,内容滚动距离与背景图滚动距离不一致(一般是背景图滚动慢于内容),从而产生视觉的差异性效果,让背景图片随着滚动有一定的溢出效果。这种效果常常用于官网的产品展示板块。
效果如图所示:
实现步骤:
- 当元素的上边框出现在视口中时,开始播放动画。
- 当元素下边框在视口中消失时,停止动画。
- 动画设置:往上滚动设置背景图片往下平移,往下滚动设置背景图片往下平移。平移的量的范围是 正负浏览器视口高度一半。
我们设置 html 元素:
<section>
<h1>111</h1>
</section>
<section>
<h1>222</h1>
</section>
...
为了节省代码量,直接使用第三方动画库 gsap 配置插件 scrolltrigger 来实现。
核心实现代码就是一个滚动动画配置:
// 遍历各个 section,添加按照滚动量计算的动画
sections.forEach(item => {
gsap.fromTo(item, {
backgroundPositionY: `-${window.innerHeight / 2}px`,
ease: 'none'
}, {
backgroundPositionY: `${window.innerHeight / 2}px`,
ease: 'none',
scrollTrigger: {
trigger: item,
scrub: true, // 动画根据滚动位置决定
}
})
});
源码可参考 视差滚动案例 demo - js实现
CSS 实现滚动加载
新版本的 chrome 支持 css 来实现滚动驱动:animation-timeline: scroll()
不作为主要方法讲述,是因为他还只是实验性功能,存在兼容性问题。当兼容性问题解决后,这个方式将成为滚动动画的主流方式。
我们创建元素:
<div id="container">
<div id="header">我是顶部内容</div>
<div id="stretcher">
我是动画区域
<div id="square"></div>
</div>
<div id="other">我是底部内容</div>
</div>
其中 stretcher 是滚动区域。
我们定义需要动画的元素样式:
#square {
position: sticky;
top: calc(50% - 50px);
width: 100px;
height: 100px;
animation-name: myAnimation;
animation-timeline: scroll();
}
#stretcher {
height: 2000px;
}
其中,animation-timeline
声明是滚动驱动,滚动的方向为从下往上,相对滚动容器为父容器 stretcher,滚动的起始点就是动画的开始点。同时声明 sticky 定位,让滚动容器 stretcher 在滚动过程中,内部执行动画的元素能够一直显示在视窗中。
我们定义一下动画 myAnimation:
@keyframes myAnimation {
0% {
opacity: 0;
transform: translateX(0) scale(0.5);
}
100% {
opacity: 1;
transform: translateX(300px) scale(1.5);
}
}
展示效果如下:
如果你想控制动画播放的起始结束时间,不想让其覆盖滚动的全周期,可以使用 animation-range-start 和 animation-range-end 属性来定义。
源码:css 滚动案例