这里介绍一种 Apple 官网非常喜欢用的一种交互动效:根据页面滚动播放视频。在 iPad、AirPods、Mac Pro 等产品的页面中,都会看到对应的实现。
下图是 AirPods Pro 充电盒动效,我们后面会实现这个动效
下面来看看具体的实现案例,本文所有案例都可以通过 nice.zuo11.com 体验效果、查看源码。
注意:本文所有 demo,仅用于探索功能实现方式,并没有处理屏幕适配问题,适用分辨率 1920x1080 / 1440x 900
1. 根据页面滚动播放视频
前段时间 iPhone 发布了新的配色,就去看了下苹果官网的更新,无意间发现 iPad mini 的一个动画比较有意思,根据页面的滚动旋转、动态操作 iPad。如下图
打开 Chrome DevTools,通过审查元素、查看 Network 中的 Media 资源,发现这里是一个 webm 格式的透明背景视频,在页面滚动的时候,根据滚动位置设置视频播放位置。这样的交互看起来真的很棒,于是就想着自己写一个 demo 实现一下。
在网上找了下,实现滚动时播放视频,有 4 种方式:
- 1、使用 requestAnimationFrame,案例: mac pro垃圾桶
- 2、setInterval + 监听页面滚动 pause() 视频
- 3、将视频转换成一帧帧图片,根据滚动切换图片,案例:Air Prods 老款动效
- 4、使用 scrolly-video 开源库
下面来逐一介绍具体实现
1.1 方式1 - Mac pro 垃圾桶 requestAnimationFrame
下面是老款 Mac Pro 垃圾桶动效,可以根据滚动播放视频
具体代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Play Video on Scroll</title>
</head>
<!-- Play Video on Scroll! https://codepen.io/marduklien/pen/MdvdEG -->
<body style="height: 10000px">
<video
id="videId"
src="https://www.apple.com/media/us/mac-pro/2013/16C1b6b5-1d91-4fef-891e-ff2fc1c1bb58/videos/macpro_main_desktop.mp4"
style="position: fixed; top: 0; left: 0; width: 100%"
muted
></video>
<script>
let playbackConst = 200;
let videoEl = document.getElementById("videId");
// Use requestAnimationFrame for smooth playback
function scrollPlay() {
var frameNumber = window.pageYOffset / playbackConst;
videoEl.currentTime = frameNumber;
// console.log(videoEl.currentTime);
window.requestAnimationFrame(scrollPlay);
}
window.requestAnimationFrame(scrollPlay);
</script>
</body>
</html>
其中,通过 requestAnimationFrame 递归执行 scrollPlay 函数,这个函数会根据当前页面的滚动距离(window.pageYOffset)动态设置视频的播放时间点(currentTime)。滚动起来视频切换比较丝滑。
playbackConst 常量值,用于调节滚动距离与视频播放时间之间的比例。常量值越小,单位滚动距离,播放的视频时长越长。但不能设置太小,不然有可能滚动一小段视频就结束了。这个 playbackConst 常量值需要根据视频时长、页面滚动高度来设置合适的值。
1.2 方式2 - setInterval + 监听页面滚动 pause() 视频
代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scrolling controls for HTML5 video</title>
</head>
<!-- Scrolling controls for HTML5 video https://codepen.io/ksiddiqi/pen/YzRmBb -->
<body style="height: 10500px;">
<video
id="videoId"
muted
src="./devstories.webm"
style="position: fixed;top: 0;left: 0;width: 100%;"
></video>
<script>
const videoEl = document.getElementById("videoId");
// pause video on load
videoEl.pause();
// pause video on document scroll (stops autoplay once scroll started)
window.onscroll = function () {
videoEl.pause();
};
// refresh video frames on interval for smoother playback
setInterval(function () {
videoEl.currentTime = window.pageYOffset / 400;
}, 40);
</script>
</body>
</html>
上面的代码中,通过 setInterval 每隔 40ms 执行一次:根据滚动位置设置视频播放时间点。和上面提到的方式 1 大致一样,都是用了一个常量来控制滚动位置与视频播放时长之间的比例。
这里比较奇怪的是,监听了页面的滚动,当页面滚动时,停止视频播放,比较神奇。恰好可以实现,当页面滚动时播放视频,停止滚动时,结束视频播放。
1.3 方式3 - 根据滚动切换 canvas 关键帧图片
下图是 AirPods 老款动效,这里和上面不一样的是,没有用 video 视频,而是通过在 canvas 上绘制一帧一帧的图片来实现动画。
代码如下:
<!DOCTYPE html>
<html lang="en" style="height: 100vh;">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scroll to control video position</title>
<style>
canvas {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: 100vw;
max-height: 100vh;
}
</style>
</head>
<!-- Scroll to control video position https://greensock.com/forums/topic/29900-scroll-to-control-video-position/ -->
<body style="height: 500vh;background: #000;">
<canvas id="hero-lightpass" />
<script>
const html = document.documentElement;
const canvas = document.getElementById("hero-lightpass");
const context = canvas.getContext("2d");
const frameCount = 148;
const currentFrame = (index) => {
console.log(index, index.toString(), index.toString().padStart(4, "0"));
// 1 '1' '0001'
// 13 '13' '0013'
// 148 '148' '0148'
return `https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${index
.toString()
.padStart(4, "0")}.jpg`;
}
const preloadImages = () => {
for (let i = 1; i < frameCount; i++) {
const img = new Image();
img.src = currentFrame(i);
}
};
const img = new Image();
img.src = currentFrame(1);
canvas.width = 1158;
canvas.height = 770;
img.onload = function () {
context.drawImage(img, 0, 0);
};
const updateImage = (index) => {
img.src = currentFrame(index);
context.drawImage(img, 0, 0);
};
window.addEventListener("scroll", () => {
const scrollTop = html.scrollTop;
const maxScrollTop = html.scrollHeight - window.innerHeight; // 总共可以滚动的距离
const scrollFraction = scrollTop / maxScrollTop; // 当前滚动距离 / 总滚动距离
// 当前图片帧 index = 滚动比例 * 总图片帧数
const frameIndex = Math.min(
frameCount - 1,
Math.ceil(scrollFraction * frameCount)
);
requestAnimationFrame(() => updateImage(frameIndex + 1));
});
preloadImages();
</script>
</body>
</html>
上面的代码中,通过 new Image() 设置 img.src 预加载所有图片,使用 window.addEventListener 监听页面的滚动,通过滚动距离设置对应图片的 index,获取对应图片,再绘制到 canvas 上。计算当前图片帧 index 逻辑如下
1、获取页面 y 轴总共可以滚动的距离
2、当前滚动距离 / 总滚动距离,得到当前滚动百分比
3、滚动百分比 * 图片总帧数 = 当前图片帧 index,如果大于总帧数,使用最大帧数图片
1.3 方式4 - 使用 scrolly-video 开源库
下面使用 scrolly-video 这个库来实现最开始我们提到的 iPad mini 官网动效
代码如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ScrollyVideo HTML Demo</title>
<style>
#scrolly-video {
width: 1200px;
height: 882px;
top: 10px !important;
clip-path: inset(0 2px 2px 0); /* 去除视频 右/下 黑边 */
background: #f5f5f7;
border-radius: 30px;
}
</style>
</head>
<body style="margin: 0; padding: 0">
<div class="scrolly-video-wrap" style="display: flex;justify-content: center;height: 300vh;">
<div id="scrolly-video"></div>
</div>
<script src="./lib/scrollyVideo.min.js"></script>
<script type="text/javascript">
new ScrollyVideo({
scrollyVideoContainer: "scrolly-video",
src: "../视频录制/assets/large_b.webm",
full: false,
});
</script>
</body>
</html>
使用 ScrollyVideo 这个库后,只需要传入视频地址,设置参数,即可实现根据滚动播放视频功能。
这里有个问题,就是使用 iPad mini 页面下载的 large_b.webm 视频滚动播放不是很流畅。这不是 scrolly-video 库的问题,我也尝试过使用方式1、方式2的实现方式,都会卡。
那只有一个原因,就是视频的问题,后面在这个库 github issue 中找到了问题所在:Why it's laggy when is scroll up
In this case, I'd recommend exporting with keyframe distance set to 1, that will solve your upwards scroll issue. The file size will be a bit bigger but it looks like you're starting with a pretty small file.
如果视频滚动不流畅,设置视频的最大关键帧距离为 1,播放就流畅了。对应 FFmpeg 命令
ffmpeg -i original.mp4 -c:v libx264 -x264-params keyint=1 original_I.mp4
keyint 是两个 keyframe 之间的最大距离,minkeyint 是两个 keyframe 之间的最小距离
什么是视频关键帧距?
关键帧距离值会告诉编码器有关重新评估视频图像,以及将完整帧或关键帧录制到文件中的频率。如果画面包含大量场景变换或迅速移动的动作或动画,那么减少关键帧距离将会提高图像的整体品质。一个较小的关键帧距离对应于一个较大的输出文件。
参考:编解码中的一些基础概念
2. webm 透明背景与最大关键帧距离
上面的例子中,由于 iPad mini webm 滚动不流畅的问题,尝试将最大关键帧最大间隔调为 1,确实可以让滚动流畅,如下图
但是会发现 webm 视频转 mp4 后,透明背景变成黑色了。因为 webm 支持透明背景,mp4 不支持透明背景,因此转换后透明背景会默认转成黑色。
于是尝试使用 Honeycam 将黑色背景替换为白色背景,但还是有黑色噪点,非透明,效果较差。
又尝试使用 Captura 录制白色背景视频,滚动切换帧流畅,但比官网模糊、非透明(设置 #f5f5f7 背景视频大部分还是白色)。
暂未找到 webm 视频设置最大关键帧距离为 1,而透明背景不丢失的方法。视频问题,暂时无解。如果有视频处理方法的大佬知道怎么处理,请不吝赐教。
3. AirPods 根据滚动播放视频切换文案
由于上面视频的问题,于是放弃实现 iPad mini 完整动画,改变方向,尝试实现 AirPod Pro 充电盒动效。如下图
这里的实现分为两步:
1、根据滚动播放视频。我们使用上面的方式 1 来实现滚动播放视频
2、根据视频播放时间点,切换不同的文案动画。具体方法是监听 video 元素的 timeupdate 方法,当视频播放时间点(currentTime)变更会触发,根据该时间判断当前需要显示的文案
vid.addEventListener("timeupdate", (e) => {
console.log(e.target.currentTime);
let t = e.target.currentTime;
// 0 - 2s 充电盒关闭,隐藏底部文案
// 2 - 3s 充电盒旋转到侧面,显示充电盒侧面文案
// 3 - 4s 充电盒旋转到底部,隐藏侧面文案
// 4 - 4.5s 显示底部文案
// 4.5. - 5s 充电盒位置回正,显示底部文案
}
比如
- 时间点小于 0.2s,step1 文案设置为 active,否则去除 active,这里加 transition 过渡时间为 1s,充电盒盒盖的时候就会隐藏 step1 文案。
- 时间点 > 2s 且 < 3s,充电盒旋转到侧面,设置 step2 文案为 active,过渡效果同上。
- 以此类推...
具体代码如下:
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>5-airpods-pro-play-video-on-scroll</title>
<link rel="stylesheet/less" type="text/css" href="./styles.less" />
<script src="../lib/less.min.js"></script>
</head>
<body>
<div class="video-wrap">
<video
id="vid"
src="./airpods-pro.webm"
muted
style="width: 690px; height: 512px"
></video>
<div class="step-1">
新的 <span class="green">U1 芯片</span>可以让你<span class="white"
>精确查找</span
>充电盒,帮你准确定位它。你还可使用查找 app 近距离查找 AirPods
Pro,看看它藏在了哪里5。
</div>
<div class="step-2">
<span class="line"></span>
<p>
<span class="white">挂绳孔</span
>方便你将充电盒系在背包或手提包上,这样就能随手取用耳机,随时沉浸在美妙的声音里6。
</p>
</div>
<div class="step-3">
<span class="line"></span>
<p>
<span class="white">内置扬声器</span>可以播放声音来帮你轻松定位充电盒,还有<span class="green">全新的铃音</span>,提示你电池电量低或是配对完成。
</span>
</div>
<div class="step-4">
<p>
AirPods Pro 和 MagSafe 充电盒都具备 <span class="white">IPX4 级别抗汗抗水性能7。</span>
</p>
</div>
</div>
<script>
// Use requestAnimationFrame for smooth playback
var frameNumber = 0, // start video at frame 0
// lower numbers = faster playback
playbackConst = 500,
// get page height from video duration
setHeight = document.getElementById("set-height"),
// select video element
vid = document.getElementById("vid");
function scrollPlay() {
// 设置当前视频播放位置 = 页面滚动距离 / playbackConst 常量 // 多少 s
// 常量值越小,单位滚动距离,播放的视频时长越长。有可能滚动一小段视频就结束了。
// 因此,这个 playbackConst 值需要可能视频时长、页面滚动高度来设置合适的值
var frameNumber = window.pageYOffset / playbackConst;
vid.currentTime = frameNumber;
console.log(vid.currentTime);
window.requestAnimationFrame(scrollPlay);
}
window.requestAnimationFrame(scrollPlay);
vid.addEventListener("timeupdate", (e) => {
// 0 - 2s 充电盒关闭,隐藏底部文案
// 2 - 3s 充电盒旋转到侧面,显示充电盒侧面文案
// 3 - 4s 充电盒旋转到底部,隐藏侧面文案
// 4 - 4.5s 显示底部文案
// 4.5. - 5s 充电盒位置回正,显示底部文案
console.log(e.target.currentTime);
let t = e.target.currentTime;
// step-1
if (t < 0.2) {
document.querySelector(".step-1").classList.add("active");
} else {
document.querySelector(".step-1").classList.remove("active");
}
// step-2
if (t > 2 && t < 3.2) {
document.querySelector(".step-2").classList.add("active");
// 精确控制宽度显示
// 1s 间隔, 宽度 0 => 429;1s = 429、(t - 2)s = (t - 2) * 429
// top 50 => 0; 1s = 0、 (t - 2)s =
// document.querySelector(".step-2 .line").style.clipPath = `inset(0 0 0 ${(3 - t) * 100}%)`
} else {
document.querySelector(".step-2").classList.remove("active");
}
// step-3
if (t > 4 && t < 4.8) {
document.querySelector(".step-3").classList.add("active");
} else {
document.querySelector(".step-3").classList.remove("active");
}
// step-4
if (t > 4.8) {
document.querySelector(".step-4").classList.add("active");
} else {
document.querySelector(".step-4").classList.remove("active");
}
});
</script>
</body>
</html>
代码写的比较随意,懒得抽公共方法了,主要是看思路。
这里实现文案动画主要是通过 active 来控制,粒度比较粗,像官网会精细一点,包括线条的长度,也会根据滚动的百分比来。
另外除了通过视频播放时间来做文案动画外,还可以使用滚动距离来判断动画显示。
4. 总结
上面我们介绍了几种在滚动时播放视频的方法,主要思路是根据页面滚动距离,设置视频播放时间点、或切换对应的视频帧图片,一般通过 window.requestAnimationFrame 来让切换变得平滑。
其中,视频是比较关键的,如果滚动时不流畅,可以尝试用 FFmpeg 将视频最大关键帧距离设置为 1。
另外,如果在滚动播放视频的同时想显示不同的文案动画,可以通过监听视频播放时间、或者页面滚动距离,设置对应的文案动效。