应项目需求,需要在订单支付成功页实现类似于支付宝的呱呱乐效果,是时候再次搬出canvas大将了。
核心代码
具体需求,奖品隐藏在浮层之下,用手轻挂浮层,浮层变透明,将挂掉的面积占总面积30%时,自动全部显示。
在这里,我们主要学习下,canvas的globalCompositeOperation属性、drawImage和getImageData函数。核心代码如下,基于Vue技术栈
export default {
data() {
return {
result: {
type: 1,
isShow: false
}
}
},
ready() {
var canvas = this.canvas = this.$els.card
var ctx = this.ctx = this.canvas.getContext('2d')
var computedStyle = getComputedStyle(canvas)
var width_seted = parseFloat(computedStyle.width),
height_seted = parseFloat(computedStyle.height);
var w = this.w = canvas.width = width_seted,
h = this.h = canvas.height = height_seted;
this.area = w * h;
this.canvas_pos = canvas.getBoundingClientRect() // 缓存位置 避免重复计算,如果是滚动容器,则不能缓存
// 设置奖品图片
var img = new Image()
img.src = '奖品图片URL'
canvas.style.backgroundImage = `url(${img.src})`;
canvas.style.backgroundSize = '100% 100%';
// 设置图层
var layer = new Image()
layer.crossOrigin = 'anonymous' // 否则getImageData报跨域错误
layer.src = '浮层图片URL'
layer.onload = () => {
ctx.drawImage(layer, 0, 0, w, h)
ctx.globalCompositeOperation = 'destination-out'; // 在原有内容之下绘制新图形 源图像 = 您打算放置到画布上的绘图。目标图像 = 您已经放置在画布上的绘图。
}
// 绑定事件
this.bindEvents()
},
methods: {
bindEvents() {
this.canvas.addEventListener('touchend', this.eventUp);
this.canvas.addEventListener('touchmove', this.eventMove);
this.canvas.addEventListener('mouseup', this.eventUp);
this.canvas.addEventListener('mousemove', this.eventMove);
},
eventMove(e) {
var offsetX = this.canvas_pos.left,
offsetY = this.canvas_pos.top;
if (e.changedTouches) {
e = e.changedTouches[0];
}
var x = (e.clientX + document.body.scrollLeft || e.pageX) - offsetX || 0,
y = (e.clientY + document.body.scrollTop || e.pageY) - offsetY || 0;
this.ctx.beginPath()
this.ctx.arc(x, y, this.radius, 0, Math.PI * 2, 0);
this.ctx.fill();
},
eventUp(e) {
var data = this.ctx.getImageData(0, 0, this.w, this.h).data,
scrapeNum = 0;
for (var i = 3, length = data.length; i < length; i += 4) {
if (data[i] === 0) {
scrapeNum++
}
}
if (scrapeNum > this.area * 0.3) {
this.ctx.clearRect(0, 0, this.w, this.h)
this.result.isShow = true
this.canvas.removeEventListener('touchmove', this.eventMove, false)
this.canvas.removeEventListener('touchend', this.eventUp, false)
this.canvas.removeEventListener('mousemove', this.eventMove, false)
this.canvas.removeEventListener('mouseup', this.eventUp, false)
}
}
toPx(rem) {
return window.baseLib.flexible.rem2px(rem)
}
}
}
跨域问题
由于浮层图片是CDN地址,在使用getImageData函数时,会有关于跨域的报错,设置crossOrigin='anonymous'
即可,具体参考链接
时隐时现
设置crossOrigin后,图片好像不稳定似的,有时候直接就不出来了,开始还以为是crossOrigin的问题,但是控制台却没有打印任何错误,差点准备将图片换成同源了,后来发现是代码的问题,一开始代码如下:
var layer = new Image()
layer.crossOrigin = 'anonymous' // 否则getImageData报跨域错误
layer.src = '浮层图片URL'
ctx.drawImage(layer, 0, 0, w, h)
ctx.globalCompositeOperation = 'destination-out'; // 在原有内容之下绘制新图形 源图像 = 您打算放置到画布上的绘图。目标图像 = 您已经放置在画布上的绘图。
在这里需要知道的事,drawImage API必须等待图片加载完成后才可以生效,否则就是个空图片,因此需要写在onload函数中。
面积计算
通过getImageData函数的data属性,可以得到整个图片的数据数组,数据可以按照每4个的顺序分组,分别表示rgba值。我们通过统计a的值为0的数目,从而可以得到已经刮开的面积。
奇怪的BUG
实际开发中,canvas元素的父元素需要居中,因此想要使用position:absolute
属性,单独使用是没有影响的,但是奇怪的是,使用了left,top,translate
等属性后,就变得很难刮开了,这个问题在Google之后,有人反馈同样的问题,但是暂时没有找到合适的解决办法。
奇怪的BUG发现了,难怪Google都找不到答案,原来是代码的问题,而不是事件没有响应,使用了会导致位置发生变化的CSS样式后,canvas的offsetX和offsetTop值并没有发生对应的改变,绘图位置不对从而导致无效!起初下面的代码是错误的
var offsetX = this.canvas.offsetLeft,
offsetY = this.canvas.offsetTop;
if (e.changedTouches) {
e = e.changedTouches[0];
}
var x = (e.clientX + document.body.scrollLeft || e.pageX) - offsetX || 0,
y = (e.clientY + document.body.scrollTop || e.pageY) - offsetY -200 || 0;
this.ctx.beginPath()
this.ctx.arc(x, y, 15, 0, Math.PI * 2, 0);
this.ctx.fill();
知识补充
在修复BUG之前,我们先来了解一些基本知识。上面代码种主要问题就是错误的使用DOM元素的offsetX
和offsetY
属性。查阅MDN可得
HTMLElement.offsetLeft
是一个只读属性,返回当前元素左上角相对于HTMLElement.offsetParent
节点的左边界偏移的像素值。通俗而言可以理解为相对于第一个有定位属性的父元素的偏移值。可见offsetTop同理。因此当父元素通过定位属性或者margin等属性造成位置变动时,offsetLeft
值是不会有变化的,而通过pageX
和clientX+scrollLeft
值,很明显,我们是希望offsetTop
是相对于文档左上角的,因为这样我们才能计算得到的x、y是相对于canvas的坐标。
接下来谈谈scrollTop和scrollLeft值,这两个元素仅针对可滚动元素而言,否则一直为0,最常用的比如document.body.scrollTop值,可以得到未出现在视口的高度,scrollLeft同理。
Element.getBoundingClientRect()方法返回元素的大小及其相对于视口的位置。这个函数不同于offset属性,其实相对于视口的位置,注意不包括滚动区域宽高。
注意:视口的位置,不包括滚动区域宽高
接下来了解事件与位置相关的属性
e.clientX表示当鼠标事件发生时,鼠标相对于浏览器视口x轴的位置,e.clientY:当鼠标事件发生时,鼠标相对于浏览器y轴的位置;
e.screenX:当鼠标事件发生时,鼠标相对于显示器屏幕x轴的位置;e.screenY:当鼠标事件发生时,鼠标相对于显示器屏幕y轴的位置;
e.offsetX:当鼠标事件发生时,鼠标相对于事件源x轴的位置;e.offsetY:当鼠标事件发生时,鼠标相对于事件源y轴的位置
e.pageX和e.pageY: 当鼠标事件发生时,鼠标相对于整个文档的X/Y坐标,在测试的时候粗心,觉得很奇怪,为什么我的页面滚动了,但是pageX和clientX一直相同。
简单而言:e.pageX = e.clientX + document.body.scrollLeft
,一直相同的原因是因为我的页面滚动不知由于body造成的,而是body的子元素。
最后一起来看看touch事件的三个属性区别
- touches:当前屏幕上所有触摸点的集合列表
- targetTouches: 绑定事件的那个结点上的触摸点的集合列表
- changedTouches: 触发事件时改变的触摸点的集合
问题解决
有了上面的知识储备,知道了问题的原因后,解决问题也就比较简单了。
使用getBoundingClientRect
函数获取left和top值取代元素的offsetLeft offsetTop
,由于不涉及到元素的滚动,且该函数比较耗时,因此在ready钩子中获取一次并缓存。这样一来不存在元素位置变动后失效的情况了。
位置偏差
在使用过程中发现,手触碰和实际位置有偏差,且越靠右偏差越大。这个问题差了好久,发现计算的位置没问题,但是实际绘制的位置却不对。我想新手都容易犯这个错误了。
原因在于我天真以为通过css给canvas设置了宽高后,canvas的宽高就等于css给定的宽高,实在是太傻太天真。因为我发现canvas的宽一直是300,我的天怎么这么奇怪呢?后来发现canvas默认画布大小为300*150。
我的解决办法就比较简单了,通过getComputedStyle
函数得到设置的宽高,然后使用js赋值给canvas的width和height属性。哇,这个世界突然就美好了
线条太小
取经之路并不简单,发现在iphone X这种高分辨率显示屏下,绘制的线条太小,我明明设置了半径10px,怎么会这么小呢。诶,不对,对于这种现实屏幕而言,确实显得小,由于项目移动端采用的是rem适配方案,因此取定一个值,然后rem2px
得到在不同显示屏下的实际半径。
哇晒晒晒