冬天来了,北方人可能不李姐南方人对雪的执念,那执念就像霓凰郡主始终相信梅长苏还活着一样,既然看不到雪,那就自己造一场雪吧,生活需要仪式感,这是技术人的浪漫。
其实是半个月之前的圣诞活动,事情是这样的:
UI:“这个圣诞版本皮肤我在首页想要一个下雪的特效。”
我:“你想要什么样的?”
UI:“下雪的那种!”
我:“那是什么样的?”
...
你倒是给我出一个效果图啊(自然是不能更小姐姐这么说的)。
最后的最后,她说:“太忙了,没时间出,你有时间就做一个吧,没有就算了。”(也是真的太忙了😄)
算了?那就算了吧😅!哈哈哈。
...
后来啊,我就陷入了这场雪的幻想中....
“雪花要洁白的,随风摇曳。“
“雪花的大小要随机,还要有毛茸茸的感觉。“
“对了,下的雪还要堆积起来”
最终现实给我一记暴扣:醒醒!
最终版本也是按照UI的标准,“雪堆积”的效果实在是没精力搞了,想出了一些思路,但是时间不够了。皮肤上线后一直想记录分享一下,奈何挺忙的,到现在才想起来记录,惭愧惭愧😥。
等后面有精力了再完善一下“雪堆积”的效果吧,到时候在更新下文章。
考虑到雪花的数量,如果用css + dom
的方式会造成页面的渲染性能,所以使用canvas
在来实现。
本篇文章是基于微信小程序的实现方案,其实在不同端用canvas
实现的原理都一样,唯一的差别是不同端提供的canvas
绘制能力上的细微差别,大同小异。
先来看看效果图(由于转换后的GIF图有压缩的情况看起来卡,但实际不卡的):
图片有点大(5M多)
特效分析
- 雪花的大小,下落的速率,飘落的方向都是随机的。
- 始终保持一定数量的雪花。
实现思路
1. 创建canvas
画布
<!-- index.wxml -->
<canvas class="canvas" type="2d" id="canvas"></canvas>
/* index.wxss */
.canvas {
width: 100vw;
height: 300px;
background: #000;
}
2.实现snow.js
借用面向对象的编程思想,将每个雪花都看做是一个独特的对象。这个对象能做两件事:
- 将雪花绘制出来
- 飘落
我们先来描述雪花的一些特征:
// snow.js
class Snow{
constructor(index,x,y){
this.index = index // 雪花编号(身份证)
this.x = x // x轴位置
this.y = y // y轴位置
this.offset = (-Math.floor(Math.random() * 3) + 1) * 0.5 // 飘落时水平偏移量
this.speed = Math.floor(Math.random() * 5 + 5) / 10 // 速度 0.5 - 1 (垂直偏移量)
this.size = Math.random() * 14 + 4 // 1 - 4 雪花直径尺寸
this.alpha = Math.floor(Math.random() * 4 + 6) / 10 // 0.7 - 1 雪花的透明度
}
}
export default Snow;
再来实现一下雪花能做的两件事:
// snow.js
class Snow{
constructor(index,x,y){
this.index = index // 雪花编号
this.x = x // x轴位置
this.y = y // y轴位置
this.offset = (-Math.floor(Math.random() * 3) + 1) * 0.5 // 飘落时水平偏移量
this.speed = Math.floor(Math.random() * 5 + 5) / 10 // 速度 0.5 - 1 (垂直偏移量)
this.size = Math.random() * 14 + 4 // 1 - 4 雪花直径尺寸
this.alpha = Math.floor(Math.random() * 4 + 6) / 10 // 0.7 - 1 雪花的透明度
}
}
/**
* @description 绘制雪花
* @param {*} ctx 画笔
* @param {*} img 雪花图标
*/
Snow.prototype.draw = function (ctx,img){
ctx.globalAlpha = this.alpha
ctx.drawImage(img, this.x, this.y, this.size, this.size)
}
/**
* @description 飘落
* @param {*} ctx 画笔
* @param {*} img 雪花图标
*/
Snow.prototype.move = function (ctx,img){
this.y += (this.speed * 2)
this.x += this.offset
this.draw(ctx,img)
return [this.x , this.y]
}
export default Snow;
move
方法返回的坐标大家可以思考一下用来做什么,后面揭晓。
3. 完善js
逻辑
// index.js
import Snow from "./snow"
Page({
/**
* 页面的初始数据
*/
data: {
canvsInfo: {}, // 画布相关信息
},
onLoad (){
const sysInfo = wx.getSystemInfoSync()
this.data.canvsInfo = {
dpr: sysInfo.pixelRatio, // 设备像素比
WIDTH: sysInfo.screenWidth, // 画布宽
HEIGHT: 300, // 画布高
COUNT: 60 , // 雪花的数量
icon: null, // 雪花图标
list: [] // 队列
}
const that = this
const timer = setTimeout(()=>{
wx.createSelectorQuery().in(this)
.select('#canvas')
.fields({
node: true,
size: true
})
.exec(that.init.bind(this))
clearTimeout(timer)
},100)
},
/**
* 初始化canvs,获取图标路径等
* @param {*} res canvs节点信息
*/
init (res) {
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const { dpr } = this.data.canvsInfo
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
this.data.canvsInfo.HEIGHT = res[0].height
ctx.scale(dpr, dpr)
const img = canvas.createImage()
img.onload = () => {
this.data.canvsInfo.icon = img
this.data.canvsInfo.ctx = ctx
// 开始绘制
this.render()
}
img.src = './snow.png'
},
/**
* 绘制雪花
* @param {*} num 雪花的数量
*/
render () {
const { WIDTH,COUNT, icon,ctx } = this.data.canvsInfo
for (let i = 0; i < COUNT ; i++){
// 随机创建下落的坐标点(100是为了大家开发中能看到效果,后面会改)
const snow = new Snow(i, Math.random() * WIDTH, 100)
// 将创建的雪花添加进队列
this.data.canvsInfo.list.push(snow)
// 绘制
snow.draw(ctx, icon)
}
}
})
以上我们已经将雪花绘制出来了,接着让它动起来。
动画原理:动画是通过把人物的表情、动作、变化等分解后画成许多动作瞬间的画幅,再用摄影机连续拍摄成一系列画面,给视觉造成连续变化的图画。它的基本原理与电影、电视一样,都是视觉暂留原理。医学证明人类具有“视觉暂留”的特性,人的眼睛看到一幅画或一个物体后,在0.34秒内不会消失。利用这一原理,在一幅画还没有消失前播放下一幅画,就会给人造成一种流畅的视觉变化效果。(摘自百度百科)
动画就是由一帧一帧的画面组成,我们要让它动来就是要不停的:清除上一次绘制 > 重新绘制 > 清除上一次绘制 > 重新绘制 ...
那要怎么让它“不停”呢?比如可以使用js
的方法setInterel
和setTimeout
。不过这两种都不是最佳方案,写动画的话还是建议使用requestAnimationFrame
方法,相比于setInterel
和setTimeout
有它独有的优势,这也是前端面试的高频考点哦,不清楚的同学可以私下去了解一下,这里我就不多赘述了,我们来继续完善:
// index.js
import Snow from "./snow"
Page({
/**
* 页面的初始数据
*/
data: {
canvsInfo: {}, // 画布相关信息
canvas:null // canvas对象
},
onLoad (){
...
},
/**
* @description 初始化canvs,获取图标路径等
* @param {*} res canvs节点信息
*/
init (res) {
...
ctx.scale(dpr, dpr)
this.data.canvas = canvas
...
img.onload = () => {
...
// 开始绘制
this.render()
// 循环绘制
this.renderLoop()
}
...
},
/**
* @description 绘制雪花
* @param {*} num 雪花的数量
*/
render (num = 0) {
...
for (let i = 0; i < COUNT ; i++){
// 随机创建下落的坐标轴
const snow = new Snow(i, Math.random() * WIDTH, (-Math.random() * 100))
...
}
},
/**
* @description 循环绘制
*/
renderLoop(){
this.animate()
const { canvas } = this.data
canvas.requestAnimationFrame(this.renderLoop.bind(this))
},
/**
* @description 雪花移动
*/
animate (){
const { ctx,icon, WIDTH, HEIGHT, list } = this.data.canvsInfo
// 清楚之前的绘制
ctx.clearRect(0, 0, WIDTH, HEIGHT)
for (let i = 0; i < list.length;i++){
list[i].move(ctx,icon)
}
}
})
这样我们的雪花就可以动起来了,到这里这场雪就“下”了差不多一大半了。
以上动画还有一些问题:
- 设置的雪花数量一次性下完就没有了,怎么让它循环起来呢?
requestAnimationFrame
方法总不能一直执行吧,会造成资源浪费。
循环的原理也很简单:
我们上面提到的move
方法返回的坐标点,我们在每次雪花移动之后判断雪花的位置是否还在可视区域内,如果在区域内则表示雪花依旧“存活”,不在区域内则表示这些雪花需要重新生成。将“消亡”的雪花再次调用render
方法绘制即可。这样就实现了视野内始终只有一定数量的雪花在飘落。
当用户离开当前页面或者销毁的时候我们应该调用cancelAnimationFrame
取消requestAnimationFrame
的执行。
完整的index.js
的代码如下:
// index.js
import Snow from "./snow"
Page({
/**
* 页面的初始数据
*/
data: {
canvsInfo: {}, // 画布相关信息
canvas:null, // canvs
isPause: false, // 是否暂停
},
onLoad (){
const sysInfo = wx.getSystemInfoSync()
this.data.canvsInfo = {
dpr: sysInfo.pixelRatio, // 像素比
WIDTH: sysInfo.screenWidth, // 设备宽度
HEIGHT: 300,
COUNT: 60 , // 随机产生雪花的数量
icon: null, // 雪花
list: [] // 队列
}
const that = this
const timer = setTimeout(()=>{
wx.createSelectorQuery().in(this)
.select('#canvas')
.fields({
node: true,
size: true
})
.exec(that.init.bind(this))
clearTimeout(timer)
},100)
},
hide (){
this.data.isPause = true
this.data.canvas.cancelAnimationFrame(this.data.aniFrameId)
},
show (){
if (!this.data.isPause){
return
}
this.data.isPause = false
this.renderLoop()
},
/**
* 初始化canvs,获取图标路径等
* @param {*} res canvs节点信息
*/
init (res) {
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const { dpr } = this.data.canvsInfo
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
this.data.canvsInfo.HEIGHT = res[0].height
ctx.scale(dpr, dpr)
this.data.canvas = canvas
const img = canvas.createImage()
img.onload = () => {
this.data.canvsInfo.icon = img
this.data.canvsInfo.ctx = ctx
// 开始绘制
this.render()
// 循环绘制
this.renderLoop()
}
img.src = './snow.png'
},
/**
* @description 绘制雪花
* @param {*} num 雪花的数量
*/
render (num = 0) {
const { WIDTH,COUNT, icon,ctx } = this.data.canvsInfo
for (let i = 0; i < (num || COUNT) ; i++){
// 随机创建下落的坐标轴
const snow = new Snow(i, Math.random() * WIDTH, num ? -3 : (-Math.random() * 100))
// 将创建的雪花添加进队列
this.data.canvsInfo.list.push(snow)
// 绘制
snow.draw(ctx, icon)
}
},
/**
* @description 循环绘制
*/
renderLoop(){
this.animate()
const { canvas,isPause } = this.data
this.data.aniFrameId = canvas.requestAnimationFrame(this.renderLoop.bind(this))
if (this.data.canvsInfo.list.length === 0 || isPause){
canvas.cancelAnimationFrame(this.data.aniFrameId)
}
},
/**
* @description 雪花移动
*/
animate (){
const { ctx,icon, WIDTH, HEIGHT, list } = this.data.canvsInfo
const alive = [] // 依然存活的雪花
// 清除之前的绘制
ctx.clearRect(0, 0, WIDTH, HEIGHT)
for (let i = 0; i < list.length;i++){
const [x,y] = list[i].move(ctx,icon)
// 在视野内(设置一定大小的阈值让动画更生动)
if (y < HEIGHT && (x > -10 && x < (WIDTH + 10))){
alive.push(list[i])
}
}
const died = list.length - alive.length
this.data.canvsInfo.list = alive
if (died > 0){
this.render(died)
}
}
})
以上就是整个动画的demo
版本了,更多细节大家可以随机应变,举一反三,这里作者就不啰嗦了。
最后
这种动画的布局一般都会脱离文档流,进行重叠(尤其是小程序会出现原生组件层级最高的问题,开发时要注意合理的布局)。这样就会影响到事件捕获,导致层级底部文档的事件无法生效,比如点击,手势等。可以使用css的 pointer-events: none
对上层文档进行屏蔽,不针对事件做出反应,对下层文档使用pointer-events: auto
进行正常接收。
好了,以上就是文章的全部内容。
最后祝福大家: 2022新年快乐,程序无bug,摸鱼时间多。