前言
前两天在浏览 苹果 16 寸 营销页面 的时候,发现了几个比较有意思的交互,心里想着自己虽然是一个穷逼,但是知识是无界限的呀,于是便研究了一波。
文章主要讲交互效果,所以文中会有很多
gif
图,大家最好连上无线再看,示例代码链接我放在了文章底部,有需要自取。
两个效果
翻盖效果
一个是屏幕慢慢打开的效果,在屏幕打开的过程中,电脑图片 是在屏幕中固定不动的,直到打开完毕或者关闭完毕的时候再让 电脑图片 随着滚动条滚动。
缩放图片
开始时是一张全屏的图片,在滚动过程中慢慢变成另一张图片,接着这张图片以屏幕正中间为基准点慢慢缩小,在缩小的过程中,这张图是定在屏幕中央的,缩小到一定值的时候,图片随着滚动条滚动。
前置知识
再动手写代码之前,我们需要了解几个在接下来代码中要用到的知识点。
粘性定位 sticky
可以简单的认为是 相对定位 relative
和 固定定位 fixed
的混合,元素在跨越指定范围前为相对定位,之后为固定定位。
sticky
元素固定的相对偏移是相对于离它最近的具有滚动框的祖先元素,如果祖先元素都不可以滚动,那么是相对于 viewport
来计算元素的偏移量。
一个例子
如下代码,html
结构如下:
<body>
<h1> 我是 sticky 的第一个 demo</h1>
<nav>
<h3> 导航 A </h3>
<h3> 导航 B </h3>
<h3> 导航 C </h3>
</nav>
<article>
<p>...</p>
<p>...</p>
// ...
</article>
</body>
样式如下:
nav {
display: table;
width: 100%;
position: sticky;
top: 0;
}
在代码中 nav
元素会根据 body
进行粘性定位,在 viewport
视口滚动到元素 top
距离小于 0px
之前,元素为相对定位,也就是说他会随着文档滚动。之后,元素将固定在与顶部距离 0px
的位置。
原理
sticky
原理大家可以看一下张鑫旭老师的 深入理解 position sticky 粘性定位的计算规则,可以先简单看一下老师讲解 sticky
时用的这个图:
其中 <nav>
是 sticky
元素,蓝色框区域是 sticky
的爸爸元素,用于承载 sticky
元素,红色区域是 <nav>
相对的可以滚动的元素。
- 当整个蓝色区域在红色区域中的时候,
sticky
元素是没有粘性效果的(如图一); - 当慢慢的向上滑的时候,蓝色的盒子超过了红色的滚动元素,那么
sticky
元素就会在蓝色的框中向下滑,实现粘性效果(如图二、三); - 当蓝色的盒子划出红色的盒子的时候,因为
sticky
元素在蓝色的框子中,所以也就直接被一波带走了,没有粘性效果(如图三)。
其实这样我们就可以很清楚的知道为什么 sticky
元素的高度为什么不能等于它爸爸的高度了,因为如果相等的话,粘性定位元素已经完全没有了实现粘性效果的空间,也就相当于失效了。
以上原理参考了张鑫旭老师的 深入理解 position sticky 粘性定位的计算规则,文章中有讲解 流盒 和 粘性约束矩形 的概念解释,以及具体的代码结构和
css
实现,大家可以查看原文。
常用例子
在业务中我们可能会遇到这样一种场景:即一个列表,列表中的数据需要根据时间显示,而且时间需要在滚动的时候固定在最顶部,这个时候我们就可以使用 sticky
来解决这个问题:
具体 html
结构如下:
<body>
<h1> 时间固定 demo</h1>
<div className={styles.wrapper}>
<section>
<h4>5 月 20 日 </h4>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</ul>
</section>
<section>
<h4>5 月 19 日 </h4>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
</section>
// ...
</body>
样式如下:
body {
margin: 0px;
padding: 100px;
height: 2000px;
}
h4 {
margin: 2em 0 0;
background-color: #333;
color: #fff;
padding: 10px;
top: 0;
z-index: 1;
position: sticky;
}
代码如上,其中每一块是一个 <section>
,然后给 <h4>
设置 sticky
定位,这样就可以实现上述效果了。
注意点
当然使用 sticky
的时候,我们需要注意几个点:
- 父级元素不能有任何
overflow:visible
以外的overflow
设置,否则没有粘滞效果。如果你设置的sticky
没有效果,可以看看父级元素们有没有设置overflow:hidden
,去掉就可以了。 - 必须指定
top
、bottom
、left
、right
4 个值之一,否则只会处于相对定位。 - 父元素的高度不能低于
sticky
元素的高度(参考上面原理解释) -
sticky
元素仅在其父元素内生效(参考上面原理解释)
还有一个不得不提的就是兼容性,我们可以在 Can I use
官网看看 sticky
的兼容性,一片红:
在 IE
下完全是废了,如果你的项目需要考虑 IE
的话,你就需要使用 fixed
来兼容了。
滚动视差 background-attachment
什么是滚动视差,来看一下下面这个例子就明白了:
视差滚动(Parallax Scrolling)是指让多层背景以不同的速度移动,形成立体的运动效果,带来非常出色的视觉体验。
上图中的效果,我们只需要一行 css
就可以实现了,不需要写复杂的 js
代码,直接设置 background-attachment: fixed
就完成了。
html
结构
<body>
<section className={`${styles.gImg} ${styles.gImg1}`}>IMG1</section>
<section className={`${styles.gImg} ${styles.gImg2}`}>IMG2</section>
<section className={`${styles.gImg} ${styles.gImg3}`}>IMG3</section>
</body>
样式代码
section {height: 100vh;}
.gImg {
background-attachment: fixed;
background-size: cover;
background-position: center center;
width: 100%;
}
.gImg1 {background-image: url(@/assets/mac1.jpg);
}
.gImg2 {background-image: url(@/assets/mac2.jpg);
}
.gImg3 {background-image: url(@/assets/mac4.jpg);
}
通过滚动视差这个 css
我们基本上可以实现第二个动画了。
关于滚动视差的讲解,大家可以参考这篇文章 滚动视差?CSS 不在话下,写的很详细。
Canvas
画图
其实第二个动画我们也可以使用 canvas
画图来实现,我们可以在一块画布中画出两张图片,根据滚动的距离,去显示两张图片在画布中的比例。
可以通过 canvas
提供的 drawImage
方法来进行画图,这个方法提供了多种方式在 Canvas
上绘制图像。
比如我们需要实现的画出如下图:
其实我们就需要截取第一章图片的上半部分,下一张图片的下半部分,然后进行拼接就 ojbk
了,看看参数解释图:
这里我们需要传入 7 个参数,来实现我们需要的效果:
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
具体参数的意思笔者也就不再这里细讲了,大家可以参考一下 drawImage() MDN 文档。
思路大致是首先绘制第一张图片,作为底图,然后我们通过绘制第二张图片,覆盖掉部分第一张图片,这样就可以实现前面提到的效果。假设我们图片的原始宽高为 2048*1024
,画布的大小为 544*341
,在滚动的时候的偏移距离为 offsetTop
,这样我们就可以写出如下代码:
function drawImage() {context.drawImage(img1, 0, 0, 2048, 1024, 0, 0, 544, 341);
context.drawImage(img2, 0, 滚动偏移距离 * 1024 / 341, 2048, 1024, 0, 滚动偏移距离, 544, 341);
}
之前笔者使用过
ctx.drawImage(image, dx, dy, dWidth, dHeight)
,可以参考笔者写的 使用 React Hooks 实现仿石墨的图片预览插件,这次用到了 7 个参数,大家可以参考这篇文章 将图片画到 canvas 上的几种方法,写的很详细。
transform
中的 matrix
CSS3 中使用 transform
可以对元素进行变换。其中包含:位移、旋转、偏移、缩放。transform
可以使用 translate/rotate/skew/scale
的方式来控制元素变换,也可以使用 matrix
的方式来控制元素变换。
举个例子:
// 代码一
transform: matrix(1.5, 0, 0, 1.5, 0, 190.5);
// 代码二
transform: scale(1,5, 1.5) translate(0, 190.5)
上面两行代码的意思是一样的,之后我们做到第二个动画的时候会用到这个属性。
如果大家想要深入了解这个属性,可以参考:大学没学过数学也要理解 CSS3 transform 中的 matrix
开撸
初始化项目
工欲善其事,必先利其器。笔者使用 react Hooks
来完成这两个动画效果,并使用 umi
快速初始化一个项目,具体的初始化步骤可以参考笔者写的 dva 理论到实践——帮你扫清 dva 的知识盲点,里面详细介绍了如何使用脚手架快速搭建一个项目。
搭建完成之后,笔者将之前讲到的例子都放在这里,方便大家点进去查看。
翻盖效果
翻盖效果其实很简单,你们绝对想不到,苹果营销页是怎么做的?
它用了 120
张图片,根据滚动距离来画出对应的在这个滚动位置上该展示的图片,对,你没有听错。我之前也以为应该是 css3
控制盖的角度从而实现翻盖效果的,是我想多了,哈哈哈。
思路
那样我们实现就很简单了,我们只需要做以下几点:
- 首先要定义一个常量,规定从盖着到完全打开需要 滚动多少距离 来完成,我们这里定义为
400px
。 - 我们需要知道什么时候开始进行翻盖或者合盖操作,这个可以让图片在屏幕正中间的时候,让其开始动画。
// 开始动画的 scrollTop
// $('#imgWrapper') 放图片的容器,html 结构下面有
startOpen = $('#imgWrapper').offset().top - (window.innerHeight / 2 - $('#imgWrapper').height() / 2);
- 当翻盖或者合盖的时候,我们需要将电脑固定在视口中,等到完全翻开或者合上的时候,再让起随滚动条滚动,这里我们可以使用
position: sticky
。
html
结构
<body>
// ...
<div className={styles.stickyContainer}>
<div className={styles.stickyWrapper}>
<div id="imgWrapper" className={styles.imgWrapper}>
<img
src={require(`@/assets/${asset}.jpg`)}
alt="图片 1"
/>
</div>
</div>
</div>
// ...
</body>
其中动态引入图片我们可以通过
require(图片路径)
来完成,如上面的代码,我们只需要计算出对应滚动距离所需要展示的图片名字即可。
样式代码
.stickyContainer {height: 150vh;}
.stickyWrapper {
height: 100vh;
position: sticky;
top: 100px;
}
.imgWrapper {
width: 100vh;
height: 521px;
margin: 0 auto;
}
.imgWrapper img {width: 100%;}
接着就是在滚动的过程中计算出当先需要显示的图片是那一张,我们上面提到:120
张图片,在 400px
的滚动距离中完成动画。
首先我们再加载完成后可以得出,我们可以得出开始动画的距离文档顶部的滚动值 startOpen
,因此我们可以得出如下代码:
useEffect(() => {
// 绑定事件
window.addEventListener('scroll', scrollEvent, false);
// 开始动画的滚动距离
// startOpen
startOpen = $('#imgWrapper').offset().top - (window.innerHeight / 2 - $('#imgWrapper').height() / 2);
return ()=>{window.removeEventListener('scroll', scrollEvent, false);
}
}, []);
// 滚动事件
const scrollEvent = () => {
// 实时的 scrollTop
const scrollTop = $('html').scrollTop();
let newAsset = ''
if (scrollTop > startOpen && scrollTop < startOpen + 400) {let offset = Math.floor((scrollTop - startOpen) / 400 * 120);
if (offset < 1) {offset = 1;} else if (offset > 120) {offset = 120;}
if (offset < 10) {newAsset = `large_000${offset}`;
} else if (offset < 100) {newAsset = `large_00${offset}`;
} else {newAsset = `large_0${offset}`;
}
}
// 边界值判断
// ....
// 设置图片 url
setAsset(newAsset);
};
预览效果
这个翻盖动画很简单,120 张图片换着来,实时渲染对应的图片,其实没有什么技术含量,大家也可以尝试一下用其他的方法实现一波。
缩放图片
缩放图片到屏幕这个动画我们可以用两个方式实现,一个是 滚动视差 实现,一个是 canvas
在滚动过程中实时渲染图片。
开始之前我们来看一下没有放大的之前图,如下:
它由两张图片组成,屏幕中显示的图片,他与 电脑外壳 的上间距是 18px
,当放大了之后,图片与 电脑外壳图片 的上边距应该是 18 * 放大比率
。
电脑外壳图片,如下:
接下来我们就开始介绍两种实现方式。
Canvas
实现
Canvas
实现是将屏幕中显示的这张图片由 Canvas
来画。
思路
其实这个动画有两部分组成,一个是 图片覆盖 ,一个是 图片缩小。
- 图片覆盖
使用 Canvas
来解决,使用 Canvas
实现我们需要使用 drawImage
方法将两张图片画到一张画布上。只需要通过滚动的距离,对应计算出具体某个时候画布应该画多少比例的第一张图,画多少比例的第二张图,就可以解决了。只需要知道什么时候开始图片覆盖。
- 图片缩小
我们使用 transform: matrix
来实现,其中图片缩小是基于屏幕正中央的点进行缩放的。
我们根据滚动的距离相应的计算出相应放大比率和 translate
的值,如下图,实时改变 transform: matrix
的参数值就行了。
这里我们需要计算出几个临界点的值,比如最大 / 小的放大比率,最大 / 小偏移值,开始缩小的点等。
- 在进行动画的时候,
canvas
包裹容器应该是sticky
定位在视口中的,直到动画结束,canvas
包裹容器才会随着滚动条滚动。
一些重要的值
这里我们需要知道几个值:
- 定义的常量
// canvas 显示的图片宽度
const CANVAS_WIDTH = 544;
// canvas 显示的图片高度
const CANVAS_HEIGHT = 341;
// 动画持续的距离
const ZOOM_SCROLL_RANGE = 400;
// canvas 显示的图片 实际宽度
const IMG_NATURAL_WIDTH = 2048;
// canvas 显示的图片 实际高度
const IMG_NATURAL_HEIGHT = 1024;
- 放大比率(
curScale
),用于matrix
的scale
值
最小的放大比率为 1,即是自身。
最大的放大比率是屏幕的高度除以屏幕显示图片的比率,这里笔者将 canvas
画出来的图片宽高定位 544 * 341
。
const CANVAS_WIDTH = 544;
const CANVAS_HEIGHT = 341;
const scaleRadio = window.innerHeight / CANVAS_HEIGHT;
所以放大比率的区间应该是
1 ~ scaleRadio
之间。
- 偏移距离(
translate
),用于matrix
的 偏移值
最大的偏移距离,应该是当 curScale
为 1 的时候,包裹元素距离视口顶部的距离,我们的缩放一直都是基于屏幕正中央这个点来进行放大 / 缩小的,所以可以很简单的得出:
// 最大的 translate
let StartScale = 0;
StartScale = window.innerHeight / 2 - $('#img-wrapper').height() / 2;
最小的偏移距离,应该是在 curScale
为 scaleRadio
时,包裹元素距离视口顶部的距离,这个时候,我们就需要用到之前提到的视屏图片到电脑外壳的 top = 18px
这个值了,因为图片进行了放大,所以最小的偏移距离应该为:
miniTranslate = - 18 * scaleRadio
所以偏移距离的区间应该是
miniTranslate ~ StartScale
之间。
- 开始缩放操作的起始点(
NewStartScale
)
其实很简单我们需要在第二章图片完全覆盖掉第一张的图片的时候就进行开始缩放,这个值可以通过 Canvas
包裹元素距离顶部文档的 top 值 加上 一屏的高度 就能计算出。
let NewStartScale = 0;
NewStartScale = $('#section-sticky-hero').offset().top + window.innerHeight;
核心代码
核心代码就是滚动时候的计算:
const scrollEvent = () => {
// 当前的 scrollTop
const scrollTop = $('html').scrollTop();
// 放大比率 默认为最大
let curScale = scaleRadio;
// 偏移距离 默认为最小
let translate = -scaleRadio * 18;
// StartScale:最大的偏移距离
// NewStartScale:开始缩放操作的起始点
// 没有就 return
if (!NewStartScale || !StartScale) return;
// 计算 当前的 curScale
// (scaleRadio - 1) / ZOOM_SCROLL_RANGE):每 1px 放大多少
// scrollTop + scaleRadio * 18 - NewStartScale:当前滚动了多少
curScale = scaleRadio - ((scaleRadio - 1) / ZOOM_SCROLL_RANGE) * (scrollTop + scaleRadio * 18 - NewStartScale);
// 边界值处理
if (curScale > scaleRadio) {curScale = scaleRadio;} else if (curScale < 1) {curScale = 1;}
// 计算 当前的 translate
// 从 scaleRadio * 18 开始
// all = scaleRadio * 18 + StartScale
// 滑动过程中不断相加
translate = -scaleRadio * 18 + ((scrollTop + scaleRadio * 18 - NewStartScale) / ZOOM_SCROLL_RANGE * (scaleRadio * 18 + StartScale));
// 边界值处理
if (translate > StartScale) {translate = StartScale;} else if (translate < -scaleRadio * 18) {translate = - scaleRadio * 18;}
// 使用 canvas 画图
if (image1 && image2) {
// 在图片覆盖阶段
// curScale 还是最大的比率
if (curScale === scaleRadio) {
drawImage({
img1: image1,
img2: image2,
secTop: CANVAS_HEIGHT * (scrollTop + 18 * scaleRadio - NewStartScale) / window.innerHeight,
});
} else {
// 如果不是最大的比率,说明图片已经覆盖完了
// 直接显示第二章
drawImage({
img1: image1,
img2: image2,
secTop: 0,
});
}
}
// 设置样式
$('#img-wrapper').css({transform: `matrix(${curScale}, 0, 0, ${curScale}, 0, ${translate})`,
});
};
html
结构如下:
<body>
// ... 其他内容
<div id="section-sticky-hero" className={styles.stickyContainer}>
<div className={styles.componentContainer}>
<div className={styles.imgWrapper} id="img-wrapper">
<canvas ref={canvasRef} id="canvas" className={styles.canvas}></canvas>
</div>
</div>
</div>
// ... 其他内容
</body>
篇幅有限,笔者只列举了滚动事件的代码和
html
结构,其他的代码,比如drawImage
这个方法,大家有兴趣的话,可以参考源码。
预览效果图
滚动视差实现
前面我们也讲了滚动视差的原理,有了这个 background-attachment: fixed
属性,第二个动画基本上已经实现一半了。
实现思路
和上面的 canvas
画图相比的话,其实就是图片覆盖的这一步不一样,其他基本上都是类似的,包括边界值的计算。
- 图片覆盖
这里我们需要将两张图片都设置为背景图片,同时我们需要给第二张图片套上 电脑外壳图片。
当第一张图片充满屏幕的时候,就给两张图片同时加上 background-attachment: fixed
属性,不能一开始的时候就加上这个属性,不然就会变成下面这个效果:
- 图片缩小
这里我们不使用 transform: matrix
来做这个放大缩小,我们使用 background-position
和 background-size
来进行图片的 缩小 / 放大和偏移。
- 其他的都与
Canvas
实现的原理大同小异。
核心代码
滚动逻辑代码如下:
const CANVAS_WIDTH = 544;
const CANVAS_HEIGHT = 341;
const WRAPPER_WIDTH = 694;
const WRAPPER_HEIGHT = 408;
const ZOOM_SCROLL_RANGE = 400;
// scalaRadio
// 图片放大的最大的倍数
const scaleRadio = window.innerHeight / CANVAS_HEIGHT;
const scrollEvent = () => {const scrollTop = $('html').scrollTop();
let curScale = scaleRadio;
let translate = -scaleRadio * 18;
if (!imgFixFixed || !StartScale) return;
// 第一张图片的 距离文档的顶部的距离为 imgFixFixed
// 第一章图片的高度为 100vh,即一屏的高度
// 所以第二章图片的 scrollTop 为 imgFixFixed + window.innerHeight
if (scrollTop > imgFixFixed && scrollTop < imgFixFixed + window.innerHeight) {
// 设置 fixed 属性
setFixImg(true);
} else {setFixImg(false);
}
// 假设我们缩放的距离是 400
// 那么我们可以计算出 每 1px 缩放的比例
// 接着一这个比例乘以滚动的距离
curScale = scaleRadio - ((scaleRadio - 1) / ZOOM_SCROLL_RANGE) * (scrollTop - imgFixFixed - window.innerHeight);
// curScale 边界值处理
// ...
// 从 scaleRadio * 18 开始
// all = scaleRadio * 18 + StartScale
// 滑动过程中不断相加
translate = -scaleRadio * 18 + ((scrollTop - imgFixFixed - window.innerHeight) / ZOOM_SCROLL_RANGE * (scaleRadio * 18 + StartScale));
// translate 边界值处理
// ...
// 设置图片的 css 样式
// 进行图片基于中心点的缩放
$('#g-img2').css({
"width": curScale * CANVAS_WIDTH,
"height": curScale * CANVAS_HEIGHT,
"margin-top": `${translate + 18 * curScale}px`,
});
$('#img-wrapper').css({
"width": scaleRadio * WRAPPER_WIDTH,
"height": scaleRadio * WRAPPER_HEIGHT,
"background-size": `${curScale * WRAPPER_WIDTH}px ${curScale * WRAPPER_HEIGHT}px`,
"background-position": `center ${translate}px`,
});
};
html
结构如下:
<body>
// ... 其他内容
<section id="g-img" className={`${styles.gImg} ${styles.gImg1} ${fixImg ? styles.fixed : ''}`}>IMG1</section>
<div className={styles.stickyContainer}>
<div className={styles.componentContainer}>
<div className={styles.imgWrapper} id="img-wrapper">
<section id="g-img2" className={`${styles.gImg} ${styles.gImg2} ${fixImg ? styles.fixed : ''}`}>IMG2</section>
</div>
</div>
</div>
// ... 其他内容
</body>
预览效果图
总结
今天讲了两个苹果营销页面的动画,文章没什么难点,主要是对几个基础知识点的运用。粘性定位 、 滚动视差、Canvas 画图、matrix 属性的使用 等等,希望对大家有所帮助。
实不相瞒,想要个赞!
参考资料
- 深入理解 position sticky 粘性定位的计算规则
- 滚动视差?CSS 不在话下
- 使用 React Hooks 实现仿石墨的图片预览插件
- 将图片画到 canvas 上的几种方法
- 大学没学过数学也要理解 CSS3 transform 中的 matrix
- dva 理论到实践——帮你扫清 dva 的知识盲点
示例代码
- 苹果营销页动画 示例代码