2013年的时候曾经使用canvas实现了一个擦除效果的需求,即模拟用户在模糊的玻璃上擦除水雾看到清晰景色的交互效果。好在2012年的时候学习HTML5的时候研究过canvas了,所以在比较短的时间内实现了一个方案【下文方案一】,后来继续探索之后进一步更新了这个方案【下文方案二】,提高了交互的性能,也提升了用户体验。
今年初的另一个项目,提出了一个比较类似的需求,不过不是擦除效果,需要在一张地图上动态显示雾霾驱散的效果,这个交互需求有个小难点,雾霾的边缘是模糊的,而不是常见的那种整齐的。
这里说明一点,用canvas实现擦除的基本原理是与视觉效果刚好相反的,从视觉和直觉逻辑上看,擦除就是擦掉表层的图像而显露出底层的图案,但是在技术实现上,刚好相反,需要被擦除的图像如模糊的玻璃是直接显示的,而擦除后显示的清晰图案则是在其上绘制的,看上去就像是擦除了模糊的玻璃。
方案一:持续重绘思路下的擦除
这个方案的思路主要是利用canvas的clip方法,该方法可以在指定的位置以特定的形状来裁剪图片,这样就可以实现蒙版效果,因为该方法在调用的时候需要指定位置,因此要实现根据手指或者鼠标动态地指定不同位置的最直接的思路就是canvas动画的基本思路--持续重绘,就是在一个持续不断的循环中调用该接口,传递给该接口的坐标是手指的实际位置。
HTML结构:
<div>
<img src="https://www.linuxidc.com/foo.jpg" />
<canvas texsrc="https://www.linuxidc.com/foo.jpg" imgsrc='bar.jpg'></canvas>
</div>
从HTML结构可以看出上面所说的【原理相反】:需要被擦除的图片(foo.jpg)是位于底层的,而擦除后显示的图片(bar.jpg)是位于上层的。因为canvas的background样式设置为了透明,这也就从视觉上欺骗了用户,它其实是在上层,但是因为透明,所以除了绘制的部分,其他部分看不见,形成它在下层的错觉。
主体JS代码如下:
function CanvasDoodle(canvas){
this.canvas=canvas;
this.ctx=canvas.getContext("2d");
this.imgSrc=canvas.getAttribute("imgsrc");
this.width=canvas.width;
this.height=canvas.height;
this.left=parseInt(canvas.style.left);
this.top=parseInt(canvas.style.top);
this.touchX=0;
this.touchY=0;
this.requireLoop=false;
this.init();
}
CanvasDoodle.prototype={
init:function(){
document.body.setAttribute("needRefresh","true");
var _self=this;
this.img=new Image();
this.img.src=this.imgSrc;
this.canvas.addEventListener('mousedown',function(e){
e.preventDefault();
_self.requireLoop=true;
_self.touchX= e.clientX-_self.left,_self.touchY= e.clientY-_self.top;
_self.loop();
},false);
this.canvas.addEventListener('mousemove',function(e){
e.preventDefault();
if(_self.requireLoop){
_self.touchX= e.clientX-_self.left,_self.touchY= e.clientY-_self.top;
}
},false);
this.canvas.addEventListener('mouseup',function(e){
e.preventDefault();
_self.requireLoop=false;
});
},
loop:function(){
if(this.requireLoop){
var _self=this;
requesetAnimFrame(function () {_self.loop()});
this.render();
}
},
render:function(){
var _self=this;
_self.ctx.save();
_self.ctx.beginPath();
_self.ctx.arc(_self.touchX,_self.touchY,15,0,Math.PI*2,true);
_self.ctx.clip();
_self.ctx.drawImage(_self.img,0,0,_self.width,_self.height,0,0,_self.width,_self.height);
_self.ctx.restore();
}
};
new CanvasDoodle(document.getElementById('CanvasDoodle'));
实际效果如图:
代码比较简单,核心部分就是render方法,根据当前鼠标或者手指的位置在canvas的上下文中绘制一个圆形,然后裁剪,这样在下一步drawImage的时候就会在上下文中绘制一个圆形的局部图形而不是整个图片。这样在鼠标或者手指移动的时候就会动态绘制很多小圆,连起来就像是擦除了。
requesetAnimFrame相信大家不会陌生,是个循环调用。这里为了节省性能,设置了一个变量requireLoop来表示是否需要重绘,只有在鼠标按下或者手指接触的时候设置为真,在每个循环中开始重绘canvas(即调用render),结束的时候则设置为假,停止绘制。
这个方案是最原始的方案,有两大缺陷:
一是循环调用,尽管有一个requireLoop可以确定是否重绘,在交互发生的时候始终是在循环,性能并不好;
二是循环调用和用户的交互速度是不同步的,理想状况是手指或者鼠标每发生变化就重绘一次,但是现实并非如此,在非常快速滑动的时候,每次动态获取的坐标并不是紧紧相连的,就造成擦除的效果不是连续的,体验会变差。
方案二:No more loops