Web 前端序列帧动画总结
前言
最近做一个项目要做到类似 Apple 官网的 macbook 产品展示的效果。
代码展示效果:
class ProductDetailImage {
constructor(canvasId) {
this.canvasId = canvasId
// 绑定 this 上下文
this.handleScroll = this.handleScroll.bind(this)
}
init() {
var mainClass = document.querySelector('main.main').classList
if (mainClass.contains('product_detail-main')) {
mainClass.remove('product_detail-main')
}
if (document.getElementById(this.canvasId)) {
this.initData()
this.initImages()
this.bindEvents()
}
}
initData() {
this.canvas2 = document.getElementById(this.canvasId)
this.context = this.canvas2.getContext('2d')
this.MAX_LEN = this.canvasId === 'product-detail1' ? 99 : 89
this.imgs = []
this.start = 1
this.oldStart = -1
this.addN = 1
this.interval = 20 // 控制刷新率
this.leftY = 0
this.curScrollY = this.canvasId === 'product-detail1' ?
document.querySelector('.js-product-scroll').offsetTop + 800 :
document.querySelector('.js-product-scroll-small').offsetTop
this.startPos = this.curScrollY
this.lastPos = this.curScrollY
this.isStop = false
}
initImages() {
for (var i = 0; i <= this.MAX_LEN; i++) {
var img = new Image()
// img.onload = () => this.imgs[i] = img
img.src = this.getImage(i)
// 不管加载否 保证顺序
this.imgs.push(img)
}
}
bindEvents() {
window.addEventListener('scroll', this.handleScroll)
}
unbindEvents() {
window.removeEventListener('scroll', this.handleScroll)
}
getScrollTop() {
return window.scrollY || 0
}
getImage(num) {
// console.assert(Number.isInteger(num) && num > -1 && num < this.MAX_LEN)
if (this.canvasId === 'product-detail1') {
return '/assets/png/cat-wipe/catwipe_' + ('' + num).padStart(3, '0') + '.png'
}
if (this.canvasId === 'product-detail2') {
return '/assets/png/cat-wipe2/catwipe_' + ('' + num).padStart(3, '0') + '.png'
}
}
isOver() {
return this.start < 0 || this.start > this.MAX_LEN - 1
}
handleScroll() {
var scrollY = this.getScrollTop()
var delta = scrollY - this.curScrollY
var isDown = delta > 0
delta = Math.abs(delta) + this.leftY
this.curScrollY = scrollY
if (
this.isStop &&
isDown === this.needDown &&
((isDown && this.curScrollY > this.lastPos) || (!isDown && this.curScrollY < this.lastPos))
) {
// console.log('start')
document.querySelector('main.main').classList.add('product_detail-main')
this.isStop = false
}
if (this.isStop) return
// good idea: 补偿 相对之前,解决了动画过快或过慢 根滚动条的速度(滚动条有快慢加速度感应,停止滚动也会有余动)来换帧
var alpha = Math.floor(delta / this.interval) * this.addN || 0
this.leftY = delta % this.interval
isDown ? (this.start += alpha) : (this.start -= alpha)
if (this.isOver() && !this.isStop) {
// this.lastPos = scrollY
this.isStop = true
// console.log('stop')
// console.log(this.start, scrollY, this.lastPos)
// TODO: 多个 canvas 用 opacity 切换
// this.canvas2.style.cssText = `opacity: 0`
}
if (this.start < 0) this.start = 0
if (this.start > this.MAX_LEN - 1) this.start = this.MAX_LEN - 1
if (this.startPos >= scrollY) this.start = 0
if (this.oldStart === this.start) return
this.oldStart = this.start
// good idea:记录
this.lastPos = scrollY
this.needDown = !isDown
this.drawCanvas(this.start)
}
drawCanvas(sequence) {
// 当前序列帧
var imgTemp = this.imgs[sequence]
var canvas = this.canvas2
canvas.width = this.canvasId === 'product-detail1' ?
document.querySelector('.js-product-width').clientWidth :
document.querySelector('.js-product-width-small').clientWidth
canvas.height = this.canvasId === 'product-detail1' ?
document.querySelector('.js-product-height').clientHeight :
document.querySelector('.js-product-height-small').clientHeight
// 计算图片的缩放比例
var scaleX = canvas.width / imgTemp.width
var scaleY = canvas.height / imgTemp.height
var scale = Math.max(scaleX, scaleY)
// 计算绘制图片的位置,使其铺满整个Canvas
var offsetX = (canvas.width - imgTemp.width * scale) / 2
var offsetY = (canvas.height - imgTemp.height * scale) / 2
this.context.drawImage(imgTemp, offsetX, offsetY, imgTemp.width * scale, imgTemp.height * scale)
}
}
<div class="row rel z3 product_detail-reveal1 | js-product-width">
<div class="product_detail-scroll-sequence | js-product-scroll">
<div class="product_detail-sequence-container">
<div class="product_detail-image-sequence">
<div class="product_detail-canvas-container | js-product-height">
<div class="product_detail-canvas-wrapper">
<canvas id="product-detail"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
.product_detail-reveal1 {
width: 100%;
}
.product_detail-reveal1 img {
height: 54.84375rem;
}
.product_detail-reveal1-title {
left: 0;
top: 3.5rem;
}
.product_detail-reveal1-logo {
right: 0;
top: 3.5rem;
}
.product_detail-reveal1-logo img {
width: 8rem;
height: 8rem;
}
.product_detail-features {}
.product_detail-features-info {
width: 50%;
}
.product_detail-features-info-desc {
margin-top: 1.1875rem;
padding-right: 8rem;
}
.product_detail-features-info-desc p {
font-size: 1.125rem;
font-weight: 500;
line-height: 1.8rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.product_detail-features-img {
width: 50%;
}
.product_detail-features-img img {
width: 80%;
height: auto;
}
.product_detail-scroll-sequence {
position: relative;
width: 100%;
height: calc(54.84375rem + 100 * 20px);
}
.product_detail-sequence-container {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.product_detail-image-sequence {
position: sticky;
position: -webkit-sticky;
top: 0;
overflow: hidden;
}
.product_detail-canvas-container {
position: relative;
width: 100%;
height: 54.84375rem;
}
.product_detail-canvas-wrapper {
transition: all 300ms;
will-change: transform;
}
上面是第一版本的代码,第一版本的业务代码主要是图片去实现逐帧效果,但是这样带来一个问题就是图片过多不好管理,虽然现在 http2
可以满足大量文件加载,可惜公司业务需求需要推翻此版本。
第二版
如果在意 Apple
的逐帧内容载体的时候,其实是用视频实现的而不是图片,视频文件确实比图片实现起来更简单更好管理,只需要注意视频帧率即可。
一般情况视频帧率是25 帧左右,即一秒视频中有 25 张图片组成的,那么简单的逻辑显示差不多如下:
// 获取视频元素
var video = document.getElementById('videoPlayer');
// 监听滚动条滚动事件
window.addEventListener('scroll', function() {
// 获取滚动条的滚动距离
var scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop;
// 计算视频播放的进度(示例中假设视频长度为 10 秒)
var videoDuration = 10; // 视频长度(秒)
var scrollMax = document.body.scrollHeight - window.innerHeight;
var videoCurrentTime = (scrollTop / scrollMax) * videoDuration;
// 更新视频播放的当前时间
video.currentTime = videoCurrentTime;
});
所以在上面核心代码中进行扩展实现如下:
class ScrollBasedVideoFrameAnimation {
constructor(videoSelector, containerSelector) {
this.video = document.querySelector(videoSelector)
this.container = document.querySelector(containerSelector)
this.frameCount = 0 // 总帧数
this.frameRate = 30 // 帧率
this.maxScroll = 0
this.handleCanPlay = this.handleCanPlay.bind(this)
this.handleScroll = this.handleScroll.bind(this)
this.init()
}
init() {
if (this.video) {
this.maxScroll = this.container.offsetTop + this.container.offsetHeight - window.innerHeight
this.video.addEventListener('canplay', this.handleCanPlay)
window.addEventListener('scroll', this.handleScroll)
}
}
handleCanPlay() {
this.frameCount = Math.round(this.video.duration * this.frameRate)
}
handleScroll() {
if (!this.ticking) {
requestAnimationFrame(() => {
const scrollTop = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop
if (scrollTop >= this.container.offsetTop && scrollTop <= this.maxScroll) {
const frameIndex = Math.floor((scrollTop - this.container.offsetTop) / (this.maxScroll - this.container.offsetTop) * this.frameCount)
// 更新视频当前时间
this.video.currentTime = frameIndex / this.frameRate
}
this.ticking = false
})
this.ticking = true
}
}
unbindEvents() {
window.removeEventListener('scroll', this.handleScroll)
}
}
// 实例对象:
const vObj = new ScrollBasedVideoFrameAnimation('#product-detail1', '.js-product-scroll')
// 销毁对象,用于 Pjax 以及单页面...
vObj.unbindEvents()
注意其中的视频帧率不一样,需要提前定义视频帧率,html
结构如下:
<div class="row rel z3 product_detail-reveal1 | js-product-scroll">
<div class="product_detail-scroll-sequence">
<div class="product_detail-sequence-container">
<div class="product_detail-image-sequence">
<div class="product_detail-canvas-container">
<div class="product_detail-canvas-wrapper">
<video id="product-detail1" :src="productDetail.productImage" loop muted playsinline preload="auto" />
</div>
</div>
</div>
</div>
</div>
</div>
css
代码如下:
/* 序列帧图 1 */
.product_detail-scroll-sequence {
position: relative;
width: 100%;
height: calc(12rem + 200rem);
}
@media (min-width: 650px) {
.product_detail-scroll-sequence {
height: calc(37.831875rem + 200rem);
}
}
.product_detail-sequence-container {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.product_detail-image-sequence {
position: sticky;
position: -webkit-sticky;
top: 30rem;
overflow: hidden;
}
@media (min-width: 650px) {
.product_detail-image-sequence {
top: 10rem;
}
}
.product_detail-canvas-container {
position: relative;
width: 100%;
height: 12rem;
}
@media (min-width: 650px) {
.product_detail-canvas-container {
height: 37.831875rem;
}
}
.product_detail-canvas-wrapper {
transition: all 300ms;
will-change: transform;
text-align: center;
margin: 0 auto;
mix-blend-mode: screen;
}
.product_detail-canvas-wrapper video {
object-fit: cover;
mix-blend-mode: screen;
width: 100%;
height: 12rem;
}
@media (min-width: 650px) {
.product_detail-canvas-wrapper video {
height: 37.831875rem;
}
}
这其中 .product_detail-image-sequence
和 .product_detail-canvas-wrapper
样式相对重要:
position: sticky
此样式可以让视频容器固定位置,给视觉上带来页面滚动的时候,视频根据滚动来播放进度条内容。will-change: transform
次样式可以让页面启用GPU
渲染,提升页面性能。
第三版
后续在 Edge
浏览器测试发现在某些电脑会有蜜汁卡顿,看了第二版的业务代码感觉也没啥问题,无奈将这问题归纳给 requestAnimationFrame
可能某些机器在特定的比如省电模式情况下出现卡顿问题,总之这问题让我毫无头绪,那只好换个方法去写了,第三版本的写法核心和第一版本差不多:
class ScrollBasedVideoFrameAnimation {
constructor(videoSelector, containerSelector) {
this.video = document.querySelector(videoSelector)
this.container = document.querySelector(containerSelector)
// this.sequence = document.querySelector(sequenceSelector)
// this.opts = opt || {
// desktop: '37.831875rem',
// mobile: '12rem',
// }
this.init()
}
init() {
const mainClass = document.querySelector('main.main').classList
if (mainClass.contains('product_detail-main'))
mainClass.remove('product_detail-main')
if (this.video) {
this.start = 1
this.oldStart = -1
this.addN = 1
this.interval = 30 // 控制刷新率
this.curScrollY = this.container.offsetTop
this.leftY = 0
this.startPos = this.curScrollY
this.lastPos = this.curScrollY
this.isStop = false
this.needDown = false
this.frameCount = 0 // 总帧数
this.frameRate = 30 // 帧率
this.handleCanPlay = this.handleCanPlay.bind(this)
this.handleScroll = this.handleScroll.bind(this)
document.querySelector('main.main').classList.add('product_detail-main')
// fixed safari
this.video.load()
this.video.addEventListener('canplay', this.handleCanPlay)
window.addEventListener('scroll', this.handleScroll)
}
}
handleCanPlay() {
this.frameCount = Math.round(this.video.duration * this.frameRate)
// console.log(this.video, this.frameCount * this.interval)
// 设置画布高度
// this.sequence.style.height = `calc(${document.body.classList.contains('is-phone') ? this.opts.mobile : this.opts.desktop} + ${this.frameCount * this.interval}px)`
}
getScrollTop() {
return window.scrollY || 0
}
isOver() {
return this.start < 0 || this.start > this.frameCount - 1
}
handleScroll() {
const scrollY = this.getScrollTop()
// console.log(scrollY)
let delta = scrollY - this.curScrollY
const isDown = delta > 0
delta = Math.abs(delta) + this.leftY
this.curScrollY = scrollY
if (
this.isStop
&& isDown === this.needDown
&& ((isDown && this.curScrollY > this.lastPos) || (!isDown && this.curScrollY < this.lastPos))
) {
// console.log('start')
this.isStop = false
}
if (this.isStop)
return
// good idea: 补偿 相对之前,解决了动画过快或过慢 根滚动条的速度(滚动条有快慢加速度感应,停止滚动也会有余动)来换帧
const alpha = Math.floor(delta / this.interval) * this.addN || 0
this.leftY = delta % this.interval
isDown ? (this.start += alpha) : (this.start -= alpha)
if (this.isOver() && !this.isStop)
this.isStop = true
if (this.start < 0)
this.start = 0
if (this.start > this.frameCount - 1)
this.start = this.frameCount - 1
if (this.startPos >= scrollY)
this.start = 0
if (this.oldStart === this.start)
return
this.oldStart = this.start
// good idea:记录
this.lastPos = scrollY
this.needDown = !isDown
this.video.currentTime = this.start / this.frameRate
}
unbindEvents() {
window.removeEventListener('scroll', this.handleScroll)
}
}
然后去实例化对象即可:
this.productDetailImage1 = new ScrollBasedVideoFrameAnimation(
'#product-detail1',
'.js-product-scroll'
);
this.productDetailImage2 = new ScrollBasedVideoFrameAnimation(
'#product-detail2',
'.js-product-scroll-small'
);