用 js canvas 做一个优雅的模拟时钟, canvas 教程实例
有很多次,我都想找到一个比较不错的,可以查看模拟时钟的网页。
有时候是想看下距离某个时间点还有多长时间,有时候是想看一下,两个时间点之间的间隔是多少。因为模拟时钟的排布比数字时钟要更直观。
但一直没有找到。
这些天闲的时候就想做个 canvas 模拟时钟玩,慢慢就做出效果来了。
目前的大致效果如下:
可访问的地址:
线上地址(白): http://kylebing.cn/tools/clock-a/
线上地址(黑): http://kylebing.cn/tools/clock-a?theme=black
github: https://github.com/KyleBing/animate-clock-canvas
一、需求
我对模拟时钟的需求有几个:
- 一定要优雅,不花哨
- 秒针一定要动的平滑,不是一秒一秒的动,而是要像 iOS 中的时钟那样
- 优雅,还是优雅,简洁而不简单
二、实现原理:表盘
你需要具备使用 canvas 绘制简单图形的能力,不需要复杂,会画个方块、圆、文字,就可以了。
很简单,直接看 MDN 官方文档上的例子就能看明白。
CanvasRenderingContext2D: canvas property
会了基础的操作之后,就说一下粗略的实现过程:
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<canvas id='canvas' width="600" height="600" style="width: 600px; height: 600px; border: 1px solid black;"></canvas>
</body>
<script>
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
ctx.fillStyle = 'magenta'
ctx.fillRect(0,0, 5, 20)
</script>
</html>
此时画面是这样的
1. 画12个小时的刻度
这里需要你会对 ctx 进行旋转,Math.PI
是半个圆的旋转角度,那么整个圆12等分后,每个刻度的旋转角度就是
let rotationAngle = Math.PI * (1/6)
这里需要注意的是,当你用 ctx.rotate() 方法旋转的时候,你旋转的是整个画布,并且它不会自动恢复到原来的样子。
也就是说,如果你 rotate() 了30度,那么画布就会在后面的绘画过程中都保持在 旋转了30度的这个状态。后面画的所有内容都是旋转 30 度的。
而这不是我们想要的,我们只需要在画这个表盘的时候旋转,在绘制完成之后恢复到原来的样子。
此时就需要 ctx 的两个方法了: save()
restore()
这两个方法的作用就是 save()
保存画布的一个状态,在后面再 restore()
恢复到保存状态时的状态。
比如我们在这个画布上旋转30度再写个文字试试:
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<canvas id='canvas' width="600" height="600" style="width: 600px; height: 600px; border: 1px solid black;"></canvas>
</body>
<script>
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
ctx.rotate(Math.PI / 6) // 旋转 30 度
ctx.fillStyle = 'magenta'
ctx.fillRect(0,0, 5, 20)
ctx.font = '50px Impact' // 字体设置
ctx.fillText('Rotate 30', 100,0) // 绘制字体
</script>
</html>
但是如果我们使用 save() 和 restore() 就能保证文字的角度正常了。
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<canvas id='canvas' width="600" height="600" style="width: 600px; height: 600px; border: 1px solid black;"></canvas>
</body>
<script>
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
ctx.save() // 保存旋转之前的画布状态
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(0,0, 5, 20)
ctx.restore()
ctx.font = '50px Impact'
ctx.fillText('Rotate 30', 100,50)
</script>
</html>
好,知道这些了就开始绘制表盘的 12 个刻度了。
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
ctx.save() // 保存旋转之前的画布状态
for (let i=0;i<12;i++){
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(0,0, 5, 20)
}
ctx.restore()
ctx.font = '50px Impact'
ctx.fillText('Rotate 30', 100,50)
能看到上图中,这几个旋转是旋转了,但中心点不对,这里需要另外一个方法,就是 translate() ,用它将左上角的 0,0 点移动到画布的中间位置,再绘制。
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
ctx.save() // 保存旋转之前的画布状态
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i=0;i<12;i++){
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(0,0, 5, 20)
}
ctx.restore()
ctx.font = '50px Impact'
ctx.fillText('Rotate 30', 100,50)
加个辅助线,再看,你应该参看到所有刻度线都没有位于中间。
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
ctx.save() // 保存旋转之前的画布状态
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i=0;i<12;i++){
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(0,0, 10, 30)
}
ctx.restore()
drawRefLines(ctx, {x: 300, y:300}) // 画参考线
ctx.font = '50px Impact'
ctx.fillText('Rotate 30', 100,50)
function drawRefLines(ctx, center){
const lineLength = 600
ctx.save()
ctx.beginPath()
ctx.moveTo(center.x - lineLength/2, center.y)
ctx.lineTo(center.x + lineLength/2, center.y)
ctx.moveTo(center.x, center.y - lineLength/2)
ctx.lineTo(center.x, center.y + lineLength/2)
ctx.strokeStyle = 'magenta'
ctx.strokeWidth = 1
ctx.closePath()
ctx.stroke()
ctx.restore()
}
所以需要处理一下偏移量
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(-5, 0, 10, 30)
}
这样就能位于中间了,接下来就是需要将刻度偏移到外围,而不是聚在中间。这个只需要在绘制它的时候在 y 轴上添加一个距离中心点的偏移量即可。比如偏移 200
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(-5, 200, 10, 30)
}
2. 画60个的分钟刻度
跟上面的小时一样,只不过这个分钟的可以调小,它的角度是 Math.PI * (1/30)
,就是半个圆的 30 等分角度,也就是:
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'magenta'
ctx.fillRect(-5, 200, 10, 30)
}
ctx.restore()
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-2, 200, 4, 20)
}
ctx.restore()
// 画参考线
drawRefLines(ctx, {x: 300, y:300})
ctx.font = '50px Impact'
ctx.fillText('Rotate 30', 100,50)
function drawRefLines(ctx, center){
const lineLength = 600
ctx.save()
ctx.beginPath()
ctx.moveTo(center.x - lineLength/2, center.y)
ctx.lineTo(center.x + lineLength/2, center.y)
ctx.moveTo(center.x, center.y - lineLength/2)
ctx.lineTo(center.x, center.y + lineLength/2)
ctx.strokeStyle = 'magenta'
ctx.strokeWidth = 1
ctx.closePath()
ctx.stroke()
ctx.restore()
}
能看到分钟的刻度在小时的刻度之上,因为分钟是后绘制的,为了避免这种情况,就需要将分钟先绘制,这两个调换一下顺序就好。
同时把小时的刻度改成黑色 black
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-2, 200, 4, 20)
}
ctx.restore()
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'black'
ctx.fillRect(-5, 200, 10, 30)
}
ctx.restore()
为了外圈统一高度,需要平衡一下这种刻度的偏移量,以小时的刻度为准,分钟的刻度在绘制的时候就需要调整下:
ctx.fillRect(-2, 200 + 10, 4, 20)
3. 画60 * 3 个的分钟刻度
咱这个例子的画布精度没有设置那么高,所以就不在每秒中间设置5个间隔了,改成3个。
那么每个间隔的旋转角度就是 Math.PI * (1/30) * (1/3)
,一圈就是 60 * 3 = 180 个
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
// 画秒刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 180; i++) {
ctx.rotate(Math.PI / 30 * (1/3))
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 20, 2, 10)
}
ctx.restore()
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-2, 200 + 10, 4, 20)
}
ctx.restore()
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'black'
ctx.fillRect(-5, 200, 10, 30)
}
ctx.restore()
// 画参考线
drawRefLines(ctx, {x: 300, y:300})
ctx.font = '50px Impact'
ctx.fillText('Rotate 30', 100,50)
function drawRefLines(ctx, center){
const lineLength = 600
ctx.save()
ctx.beginPath()
ctx.moveTo(center.x - lineLength/2, center.y)
ctx.lineTo(center.x + lineLength/2, center.y)
ctx.moveTo(center.x, center.y - lineLength/2)
ctx.lineTo(center.x, center.y + lineLength/2)
ctx.strokeStyle = 'magenta'
ctx.strokeWidth = 1
ctx.closePath()
ctx.stroke()
ctx.restore()
}
结果就是上面这样,能看到每个分钟间隔内等分了3份。
好像看起来不是太好看,只需要调整下刻度的大小就好了。这些刻度的大小最后也可以摘取成配置项,方便修改,我 github 已写好的程序上就都改成了配置项。
这里我把分钟和秒的刻度都改成同样宽度的线条,只是高度不一样,结果就是下面这样。
// 画秒刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 180; i++) {
ctx.rotate(Math.PI / 30 * (1/3))
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 20, 2, 10)
}
ctx.restore()
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 10, 2, 20)
}
ctx.restore()
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'black'
ctx.fillRect(-2, 200, 4, 30)
}
ctx.restore()
质感一下子就来了。
好了,到此,表盘已经画好了。
三、实现原理:时针、分针、秒针
1. 刷新画面内容
上面这些步骤的内容都是死的,就是独帧的内容,如果想让它动起来,就需要让它一次次的刷新里面的内容。
这里就需要补一下关于 canvas 的动画知识。
canvas 的内容刷新的驱动并不是靠 timeInterval
这种定时执行的,而是靠浏览器自带的一个方法:
window.requestAnimationFrame()
这个方法内部执行的就是 canvas 下次刷新需要执行的方法。一般它的执行频率是你所用设备的屏幕刷新率,比如你的电脑屏幕刷新率是 30hz,那它就是每秒执行30次,手机一般高刷的就是 120hz,也就是每秒执行 120 次。
它还有一个优点就是,在界面没有被展示的时候,它是不会被执行的。也就是说,当画面切于后台时,不执行,只有显示在前台的时候才会被执行。而 timeInterval
方法会在界面从后台切到前台显示时成堆的压到一直执行,所以它不适合作动画。
这个方法的意思是就是 “ 当前画面已经绘完了,我要绘制下一副内容了,我应该怎么绘制?”
我们这个例子里,其实下一帧需要绘制的内容还是我们之前绘制表盘的整个方法,这里我们就需要做一下整理了,把它整理成一个方法。
一般我们把这个方法名命名为了 draw()
将我们之前代码整理之后就是这样:
我加了一个名为 timeLine
的变量,用于记录画面刷新的总次数,每次绘制的时候都 + 1
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<canvas id='canvas' width="600" height="600" style="width: 600px; height: 600px; border: 1px solid black;"></canvas>
</body>
<script>
let timeLine = 0
window.addEventListener('load', () => {
draw()
})
function draw() {
timeLine = timeLine + 1
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
// 画秒刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 180; i++) {
ctx.rotate(Math.PI / 30 * (1 / 3))
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 20, 2, 10)
}
ctx.restore()
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 10, 2, 20)
}
ctx.restore()
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'black'
ctx.fillRect(-2, 200, 4, 30)
}
ctx.restore()
// 画参考线
drawRefLines(ctx, {x: 300, y: 300})
ctx.font = '50px Impact'
ctx.fillText(timeLine, 100, 50)
}
function drawRefLines(ctx, center) {
const lineLength = 600
ctx.save()
ctx.beginPath()
ctx.moveTo(center.x - lineLength / 2, center.y)
ctx.lineTo(center.x + lineLength / 2, center.y)
ctx.moveTo(center.x, center.y - lineLength / 2)
ctx.lineTo(center.x, center.y + lineLength / 2)
ctx.strokeStyle = 'magenta'
ctx.strokeWidth = 1
ctx.closePath()
ctx.stroke()
ctx.restore()
}
</script>
</html>
上面这个内容还没有添加让它动起来的方法,所以它的显示是这样:
我们把上面提到的方法添加上,它应该添加到 draw() 方法的末端。
function draw(){
// 刷新画面内容
window.requestAnimationFrame(draw)
}
这样再刷新页面的时候能看到这样的结果:
这是为什么呢? 这是因为在这一次次的 draw()
过程中,我们并没有在每次刷新之前清空画布,所以它就会将所有的内容都重叠在了一起。
我们需要做的就是在每次绘制内容之前执行一下 ctx.clearRect(0,0, 600,600)
清空画布。
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<canvas id='canvas' width="600" height="600" style="width: 600px; height: 600px; border: 1px solid black;"></canvas>
</body>
<script>
let timeLine = 0
window.addEventListener('load', () => {
draw()
})
function draw() {
timeLine = timeLine + 1
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
// 清空画布
ctx.clearRect(0,0, 600,600)
// 画秒刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 180; i++) {
ctx.rotate(Math.PI / 30 * (1 / 3))
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 20, 2, 10)
}
ctx.restore()
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 10, 2, 20)
}
ctx.restore()
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'black'
ctx.fillRect(-2, 200, 4, 30)
}
ctx.restore()
// 画参考线
drawRefLines(ctx, {x: 300, y: 300})
ctx.font = '50px Impact'
ctx.fillText(timeLine, 100, 50)
// 刷新画面内容
window.requestAnimationFrame(draw)
}
function drawRefLines(ctx, center) {
const lineLength = 600
ctx.save()
ctx.beginPath()
ctx.moveTo(center.x - lineLength / 2, center.y)
ctx.lineTo(center.x + lineLength / 2, center.y)
ctx.moveTo(center.x, center.y - lineLength / 2)
ctx.lineTo(center.x, center.y + lineLength / 2)
ctx.strokeStyle = 'magenta'
ctx.strokeWidth = 1
ctx.closePath()
ctx.stroke()
ctx.restore()
}
</script>
</html>
结果就是这样
2. 绘制秒针
我们每次刷新都执行 draw()
这个方法,在每次刷新之后我们都需要重新获取一下当前时间,再根据时间去绘制每个指针。
比如我们通过当前时间可以获取到
- 秒
new Date().getSeconds()
- 分
new Date().getMinutes()
- 时
new Date().getHours()
上面过程中我们已经学会绘制一个随角度变化再变化的指针了。
拿秒针为例,我们只需要计算出此时此刻的秒针应该旋转的角度就可以了。
我们先绘制一个不会动的秒针,在 draw() 里添加如下内容
// 绘制秒针
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
ctx.fillStyle = 'green'
ctx.fillRect(-10, 0, 20, 200)
ctx.restore()
能看到,指针的初始方向并不是我们想要的角度,我们需要的初始位置在上面,也就是相差一个 Math.PI
的角度,这里我们选择 + Math.PI
// 绘制秒针
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
ctx.rotate(Math.PI)
ctx.fillStyle = 'green'
ctx.fillRect(-10, 0, 20, 200)
ctx.restore()
好了,现在我们就要让它动起来了。
上面我们已经可以获取到当前的秒数 new Date().getSeconds()
。
对应的旋转角度是,半圆的 30 等分。
let seconds = new Date().getSeconds()
let rotateAngle = Math.PI * (1/30) * seconds
// 绘制秒针
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
let seconds = new Date().getSeconds()
let rotateAngle = Math.PI * (1/30) * seconds
ctx.rotate(Math.PI + rotateAngle)
ctx.fillStyle = 'green'
ctx.fillRect(-10, 0, 20, 200)
ctx.restore()
效果就是:
它现在是按秒动的,我们需要它动的更平滑。
同时我们把秒针改成细长的红色。
这里就需要引入时间对象中一个不太常用的 毫秒 ms 值了,1 秒 = 1000 毫秒
const seconds = new Date().getSeconds()
const ms = new Date().getMilliseconds()
那么秒针的准确值就应该是 秒 + 毫秒
,每秒是 1000 毫秒,那它的实时角度就应该是
const rotateAngle = Math.PI * (1/30) * seconds + ms / 1000 * Math.PI / 30
放进原程序后的完成代码就是
<!DOCTYPE html>
<html lang="zh-CN">
<body>
<canvas id='canvas' width="600" height="600" style="width: 600px; height: 600px; border: 1px solid black;"></canvas>
</body>
<script>
let timeLine = 0
window.addEventListener('load', () => {
draw()
})
function draw() {
timeLine = timeLine + 1
let canvasItem = document.querySelector('#canvas')
let ctx = canvasItem.getContext("2d")
// 清空画布
ctx.clearRect(0,0, 600,600)
// 画参考线
drawRefLines(ctx, {x: 300, y: 300})
// 画秒刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 180; i++) {
ctx.rotate(Math.PI / 30 * (1 / 3))
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 20, 2, 10)
}
ctx.restore()
// 画分钟刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 60; i++) {
ctx.rotate(Math.PI / 30)
ctx.fillStyle = 'gray'
ctx.fillRect(-1, 200 + 10, 2, 20)
}
ctx.restore()
// 小时刻度
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
for (let i = 0; i < 12; i++) {
ctx.rotate(Math.PI / 6)
ctx.fillStyle = 'black'
ctx.fillRect(-2, 200, 4, 30)
}
ctx.restore()
// 绘制秒针
ctx.save()
ctx.translate(300, 300) // 将 0,0 移动到画布中间
const seconds = new Date().getSeconds()
const ms = new Date().getMilliseconds()
const rotateAngle = Math.PI * (1/30) * seconds + ms / 1000 * Math.PI / 30
ctx.rotate(Math.PI + rotateAngle)
ctx.fillStyle = 'red'
ctx.fillRect(-2, 0, 4, 220)
ctx.restore()
ctx.font = '50px Impact'
ctx.fillText(timeLine, 100, 50)
// 刷新画面内容
window.requestAnimationFrame(draw)
}
function drawRefLines(ctx, center) {
const lineLength = 600
ctx.save()
ctx.beginPath()
ctx.moveTo(center.x - lineLength / 2, center.y)
ctx.lineTo(center.x + lineLength / 2, center.y)
ctx.moveTo(center.x, center.y - lineLength / 2)
ctx.lineTo(center.x, center.y + lineLength / 2)
ctx.strokeStyle = 'magenta'
ctx.strokeWidth = 1
ctx.closePath()
ctx.stroke()
ctx.restore()
}
</script>
</html>
怎么样,这样就变得很丝滑了。
接下来就是你自己需要去添加分针和时针了。上面就是完整的实例代码,直接保存为 html,在浏览器中打开就能运行。
给个提示:
- 分针也是需要计算 ms s 来计算角度的,不然它只会在分钟变化时变化。
- 时针需要考虑 s min, ms 可以不用考虑。
- 你可能需要让它自动计算画面的最中心点在哪。
- 慢慢去一点点的优化它,直到你理想的状态。
四、最终效果
线上地址: http://kylebing.cn/tools/clock-a/
github: https://github.com/KyleBing/animate-clock-canvas
结语:canvas 是个非常好玩的玩意,可以做你想做的任意效果,发挥你的想象力去创造有趣的东西吧。