Better

Ethan的博客,欢迎访问交流

canvas实现刮刮乐

应项目需求,需要在订单支付成功页实现类似于支付宝的呱呱乐效果,是时候再次搬出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元素的offsetXoffsetY属性。查阅MDN可得

HTMLElement.offsetLeft是一个只读属性,返回当前元素左上角相对于HTMLElement.offsetParent节点的左边界偏移的像素值。通俗而言可以理解为相对于第一个有定位属性的父元素的偏移值。可见offsetTop同理。因此当父元素通过定位属性或者margin等属性造成位置变动时,offsetLeft值是不会有变化的,而通过pageXclientX+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得到在不同显示屏下的实际半径。

参考



留言

nothing
2018-11-06 13:00

哇晒晒晒