如何使用Canvas实现一个时间控件

avatar
Mofei Zhu

前段时间在一个分享上提及到了我们的时间穿梭控件,分享之后很多同学在Github的Issue中留言想了解该控件的具体开发过程,利用周末的时间将该控件单独重新写成一个Demo,和大家分享一下具体的技术实现细节。

Image

重新整理的控件开源在Github上 https://github.com/zmofei/timeplayer ,也欢迎大家在Issue中对代码提出问题或者建议,共同探讨一起进步。本文也会结合代码对其实现细节进行说明。从上图中可以看出控件分为2个部分:一个顶端的播放控制模块,一个下面的时间刻度模块,由于篇幅有限我们先介绍通过Canvas实现的刻度。内容和代码高度相关,建议大家结合代码一起看文章。

一、初始Canvas,以及高清屏的处理

在通过Canvas绘制任何图形之前,我们需要正确的初始化Canvas,在代码/src/timeplayer.ts的第 ?L133 行我们通过setupCanvas()方法创建并放置了一个Canvas,同时也获取到了该Canvas的上下文ctx。以上过程相对来说比较简单,着重介绍一下其中处理Canvas适配高清屏的代码 ?L144-L148

// scale canvas
var dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);

这里我们使用了 ?Window.devicePixelRatio 来获取屏的像素大小比值。像素比可以理解成一个PX像素对应多少个物理像素,我们用一张图简单说明一下:

Image

假设在devicePixelRatio为1的情况下1个px对应一个物理像素,那么在这个情况下在10px * 10px的大小尺寸下刚好有10 * 10个物理像素,一张10px * 10px的图片也刚好可以平均分配给所有的物理像素,这样图片看起来就不会出现问题。但是当devicePixelRatio为2的情况下事情就变了,在这种高清屏中10px * 10px的范围内含有20 * 20个物理像素,即每4个物理像素(2 * 2)来表示1个px,所以对于同样一张10px * 10px的图片来说,如果按照比较清晰的表示方法(1个px对应一个物理像素),那么输出的图片大小会变成之前的1/4,为了保证输出的图片大小不变渲染的时候只能把原始图片放大为原来的4倍,然后在渲染把每一个1px分割成使用4个物理像素,我们都知道非矢量图片放大对于图片本身来说是有损失的,所以如果在不进行特殊处理的情况下同样的一张canvas放到高清屏上就会变得模糊。

既然知道了为什么模糊,那么我们是不是可以在绘制的时候就准备一个“大号的”画布,然后在这个大号的画布上进行绘制?结合到我们实际绘制Canvas的内容的时候都是根据“矢量”坐标进行绘制的,所以对于高清屏,我们只需要在初始化的时候把Canvas的大小按照像素比放大,然后再按照比例去绘制我们的内容,问题就会得到解决。

所以回到我们的代码中,我们首先获取到屏幕的像素比devicePixelRatio,然后把Canvas的大小根据像素比进行放大(注意此时我们放大的是Canvas的with和height,但是Canvas的CSS大小即Style的width和height我们还是保持不变)。经过这个操作之后一张10px * 10px的CSS大小的Canvas就会被放大成20px * 20px,解决了模糊的问题。但是又会带来另外一个问题,比如说我们要在5px * 5px的地方绘制一个点,但是由于画布被放大了这个点的位置就变成了 10px * 10px,我们需要在绘制的时候进行坐标的转换,为了避免这种坐标转换的工作我们使用了canvas的 ?CanvasRenderingContext2D.scale()方法,把canvas的上下文同比放大,这样我们在绘制的时候就不需要再次对坐标进行处理,直接按照原始的值绘制即可。

完成了画布的准备工作我们就可以正式的开工啦!

二、绘制基本流程和技术细节

绘制过程我们放在了 ?draw() 这个方法中。这个方法完成了主要的绘制过程,绘制开始前会 1. 清除屏幕内容 -> 2.绘制刻度 -> 3.绘制悬停、选中的日期。他会在适当的时机(通常是绘制内容发生变化的时候如,日期变更,鼠标在控件上移动等)被触发,每次都会依次按照上述的流程进行绘制。

用到的Canvas技术细节:

上文提到在绘制的过程中我们主要按顺序进行了以下三种绘制: * 绘制刻度尺:?drawScale() * 绘制悬停状态:?drawHover() * 绘制选中状态:?drawActive()

结合代码,可以看到在绘制的过程中运用到了一些Canvas的API接口,这里对主要用到的方法简单进行说明:

1. 绘制直线

在Canvas中,如果需要绘制直线可以通过以下的流程来实现:

  • 开始路径:?ctx.beginPath(),该方法用于创建一个新的绘制路径。
  • 设置路径路径: ?ctx.moveTo() ?ctx.lineTo(),其中moveTo方法是用来移动子路径的起点,lineTo方法是将该点和路径中的上一个点进行连接,可以通过下图帮助理解: Image 该图表示了绘制一个十字形的线条的过程,可以尝试去理解1-5的步骤中都发生了什么,尤其需要特别注意理解3和4之间使用的MoveTo()方法
  • 设置线条的宽度:?ctx.lineWidth()
  • 绘制线条/描边:?ctx.stroke() 上述的所有方法均不会在画布上绘制出任何痕迹直到我们调用了绘制相关的方法ctx.stroke()ctx.fill()等,才会最终在画布上进行绘制。

典型的示例代码如下: javascript // ctx为canvas的上下文对象 ctx.beginPath(); ctx.moveTo(100, 20); ctx.lineTo(100, 100); ctx.lineWidth = 1; ctx.strokeStyle = 'red'; ctx.stroke();

2. 绘制矩形

绘制矩形点的方法非常容易主要是通过 ?fillRect() 实现的,在绘制之前可以通过 fillStyle属性对绘制的矩形的样式进行调整,如:

ctx.fillStyle = "green";
ctx.fillRect(10, 10, 100, 100);

会在坐标为 [10,10] 的位置绘制一个 100px * 100px 的绿色矩形。

如果仅需要绘制矩形的边框(而不是填色)可以通过 ?strokeRect() 方法来实现,同样绘制之前可以通过strokeStyle来调整边框的颜色,lineWidth等来配置线的宽度等。

点的绘制过程相对来说比较容易,只需要按照日期点的个数计算好坐标即可,但是在实际的绘制中我们需要考虑到如果日期数量过于庞大所有的点就会密集的聚集在一起,这种情况对于使用者来说并不友好。所以,在设计的时候,我们定义了最小的点间距(10px,用户也可以根据自己的需要进行调整),当坐标点小于这个范围的时候,我们会把节点用更小的点表示,以使得整体变得更加美观。

Image

具体实现可以查看代码的 L233-L260 行。其中的for循环是拿到所有的日期点,并绘制将每一个计算后的日期点按照坐标以小点的形式绘制在画布上,同时在每次绘制的时候累加点之间的间距,当距离大于一定的值之后会将下一个点被记录到 bigPoints 数组中(即我们看到的比较大的节点),然后在小点绘制完成之后,再单独的遍历并绘制大的节点。

3. 绘制圆形团

想要绘制工具中出现的圆形悬停、选中区域,可以通过绘制圆弧的方式来实现: * ?arc():用来绘制圆弧路径,这里由于我们是需要绘制一整个圆,所以其中的起始角度我们可以固定成0°到360°即[0, 2 * Math.PI],一个示例如下: ctx.arc(75, 75, 50, 0, 2 * Math.PI); ctx.fill(); 该示例会在坐标[75, 75]的位置绘制一个半径为50的圆形(0,2 * Math.PI两个参数即上文提到的起始角度)。

由于arc()方法也是路径级别的操作,所以单纯的执行arc()方法并不会在画布上留下任何痕迹,我们需要在设置好路径之后,通过fillStylestrokeStyle两个属性来控制填充色和描边样式,然后通过fill()stroke()方法对其进行填色和描边操作。

完整的绘制圆的步骤在代码的 ?L267-L273?L305-L312 中可以查看。

4. 绘制文本和文本背景

Canvas绘制文本还是比较方便的,最简单的可以直接通过?fillText()方法进行绘制,在绘制左右两边的文本的时候,我们可以通过设置ctx.textAlign = 'right'或者ctx.textAlign = 'left'以方便坐标计算,具体的代码可以参见?L277-L297

Image

对于文本的背景来说,由于每次显示的文本的长度会根据文本的内容发生变化,所以我们需要在绘制之前拿到文本的宽度,这里我们使用了?measureText()方法获取这个值,然后结合之前的绘制多边形的方法fillRect就可以很容易的把背景绘制出来了。

5. 选中/悬停的判断

最后说一下如何实现鼠标的选中和悬停,Canvas不像DOM可以在通过简单的在每个对象上(时间节点上)绑定hover或者click事件,我们只能将事件绑定在整个Canvas画布上,然后通过获得鼠标相对于Canvas的的坐标来计算鼠标是悬停/点击了哪一个节点。在代码的第L113行我们初始化Canvas之后就进行了该事件的绑定this.setupEvens();

观察其中的mousemove或者onclick事件(?L157-L163),不难发现我们首先通过 e.offsetX 拿到鼠标在Canvas中的坐标,然后计算出鼠标的坐标相对于整个可用宽度的百分比,再用这个百分比乘以时间的总数,四舍五入之后就能拿到一个数值,这个数值就是对应的时间数组中的索引值(index),对应的时间文本就是 dates[index]

在可以感知用户鼠标悬停或者点击的位置之后,我们需要提供接口让用户监听/取消日期的变化。这里我们通过?on()?off()方法将事件回调放到this.events[type]数组中。每次切换日期的时候,系统都会去?检查this.events[type]是否有绑定的函数,如有则执行。

总结

以上对时间控件中的Canvas绘制过程进行了简单的描述,其实绘制过程并不难,主要是对Canvas的各个接口的正确使用,大家可以结合代码去看,如果有任何问题可以给我留言。