现在很多app,在一些隐私页面,往往都会加入二次验证,例如银行app、支付宝理财和我的页面,一般会有「九宫格密码」和指纹密码。
今天我们用canvas来写一个九宫格手势密码锁,大概就是下面这样。
思路
- 准备一个正方形画布
- 找到9个小圆圈的圆心坐标(位置自己定,布局合理即可)
- 绘制圆圈
- 监听手势并连接小圆圈
实现
第一步:先初始化一个空白画布
<canvas id="canvas"></canvas>class GesturePassword {// 正方形,宽高都一样,就用一个size了// padding 画布的边距,百分比constructor(canvas, {size = 300, padding = 0.08} = {}) {this.canvas = canvas;this.ctx = canvas.getContext("2d");this.size = size; // 计算画布实际的padding大小this.padding = size * padding; // 初始化一些属性this.init();}init() {const { ctx, canvas, size } = this;canvas.width = size;canvas.height = size;// 为了开发时看得清楚,先把背景设为深色ctx.fillStyle = "#000";ctx.fillRect(0, 0, size, size);}
}
第二步:画9个小圆
canvas画圆API
ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);
- x:圆心的 x 轴坐标。
- y:圆心的 y 轴坐标。
- radius:圆的半径
- startAngle:圆弧的起始点,x 轴方向开始计算,单位以弧度表示。
- endAngle:圆弧的终点,单位以弧度表示。
- anticlockwise(可选):可选的Boolean值,如果为 true,逆时针绘制圆弧,反之,顺时针绘制。
找圆心坐标和半径
定义函数
// 计算圆的坐标
calcCirclePos() {const { size, padding } = this;// 去除画布padding之外的内容宽高const contentSize = size - padding * 2; // 除去圆与圆之间的距离// 规定每个小圆的直径是总宽度的24%const circleWidth = contentSize * 0.24; // 每两个圆圈的圆心之间的距离,横竖都一样const distance = (contentSize - circleWidth) / 2; // 左上角第一个圆的圆心坐标,x和y都一样const firstPoint = Math.ff(circleWidth / 2); // 综上,第一行三个圆的x轴坐标如下const xy = [firstPoint,Math.ff(firstPoint + distance),Math.ff(firstPoint + distance * 2)];// 由于横竖每个圆之间的间隔都是一样的,// 所以很容易想到,通过以上三个值遍历就可以得出9个圆的圆心const points = [];let i = 0;while (i < 3) {for (let index = 0; index < xy.length; index++) {const element = xy[index];points.push({ x: element, y: xy[i] });}i++;}// 最后还要加上padding才是圆心在画布内的真实位置return {points: points.map((item) => {return {x: Math.ff(item.x + padding),y: Math.ff(item.y + padding)};}),circleWidth};
}
Math.ff是为了解决浮点数计算丢失精度问题的
// 浮点数计算,f代表需要计算的表达式,digit代表小数位数
Math.ff = function(f, digit = 2) {// Math.pow(指数,幂指数)const m = Math.pow(10, digit);// Math.round() 四舍五入return Math.round(f * m, 10) / m;
};
在init中调一下
init() {// ...前面的省略了// 计算九个圆圈的圆心的坐标和直径大小const { points, circleWidth } = this.calcCirclePos();// 存起来this.points = points;this.circleWidth = circleWidth;
}
绘制小圆
定义画圆函数
drawCircle() {const { points, circleWidth, ctx } = this;// 循环绘制9个圆points.forEach((item, index) => {// 每一次都要重新开始新路径ctx.beginPath();ctx.arc(item.x, item.y, circleWidth / 2, 0, Math.PI * 2);ctx.closePath();// 将线条颜色设置为蓝色ctx.strokeStyle = "#217bfb"; // stroke() 方法默认颜色是黑色(如果没有上面一行,则会是黑色)ctx.stroke(); });
}
看看效果
第三步:监听手势
这里要判断一下是什么设备,电脑上就监听mouse事件,手机上就监听touch事件,不过这个效果一般是在手机上用的。
这里有两个辅助函数
- 计算触摸/鼠标移动到的当前坐标
- 用拿到的当前坐标,和9个小圆坐标以及圆的半径对比,判断是否滑动到了圆圈内
const { canvas } = this;
// 判断设备
const isMobile = /Mobile|Android/i.test(navigator.userAgent);if (isMobile) {// 监听触摸开始事件canvas.addEventListener("touchstart",(e) => {// 这里要判断一下是几指触摸,只允许单指触摸if (e.touches.length !== 1) return;// 获取触摸的坐标位置const { x, y } = this.getTouchPosition(canvas, e.touches[0]);// 判断是否滑动到了圆圈内,是就返回圆的坐标const point = this.trigger(x, y);console.log("[ this.trigger(x, y) ] >", point);if (!point) {// 没有返回坐标,就说明没有滑到任何一个小圆内,就不用管return}// 把被触发的小圆坐标存起来this.hitPoints.push(point);// 绘制触发后的样式和连线this.drawHitCircle();},false);// 监听触摸移动事件canvas.addEventListener("touchmove",(e) => {// 防止页面跟着移动e.preventDefault();if (e.touches.length !== 1) return;const { x, y } = this.getTouchPosition(canvas, e.touches[0]);const point = this.trigger(x, y);console.log("[ this.trigger(x, y) ] >", point);if (!point) {// 没有返回坐标,就说明没有滑到任何一个小圆内,就不用管return}if (this.hitPoints.includes(point)) {// 如果那个位置已被命中过了,就不管return}// 把被触发的小圆坐标存起来this.hitPoints.push(point);// 绘制触发后的样式和连线this.drawHitCircle();},{ passive: false });canvas.addEventListener("touchend", async () => {if (this.hitPoints.length < 4) {setTimeout(() => {// 这里用计时器的作用是防止alert阻塞正常逻辑alert('密码无效,至少需要四个点')}, 0)} else {// 密码有效将密码传给后端或存起来await http()// 然后清空临时存储的点this.hitPoints = [];}// 重新绘制this.drawHitCircle();});
} else {// 非手机端,逻辑一致,不同的是监听方法不同
}
定义获取触摸坐标的函数
getTouchPosition(canvas, event) {// 获取画布相对于浏览器窗口的位置信息// 当画布不在浏览器左上角时必须这么计算const rect = canvas.getBoundingClientRect();const x = event.pageX - rect.left;const y = event.pageY - rect.top;return { x, y };
}
判断是否进入了某个圆圈内
// 接收触摸位置的坐标 x,y
// 判断手指进入了某个圆圈内,返回圈圈坐标
trigger(x, y) {// 先得到被命中的圆圈下标const index = this.points.map((item) => {const distance = Math.sqrt((x - item.x) ** 2 + (y - item.y) ** 2);return distance < this.circleWidth / 2;}).findIndex((item) => item);// 返回该坐标return this.points[index];
}
第四步:绘制命中后的样式
遍历之前存的hitPoints坐标数组,将圆环变为蓝色,并在内部画一个小圆填充
// 绘制命中后的圆圈样式
drawHitCircle() {const { hitPoints, ctx } = this;console.log("[ hitPoints ] >", hitPoints);if (hitPoints.length === 0) {// 手指离开画布后会清空坐标,此时清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 但是要重新画圆圈drawCircle();return;}hitPoints.forEach((item, index) => {ctx.beginPath();ctx.arc(item.x, item.y, this.circleWidth / 2, 0, Math.PI * 2);ctx.closePath();// 将线条颜色设置为蓝色ctx.strokeStyle = "#217bfb"; // stroke() 方法默认颜色是黑色(如果没有上面一行,则会是黑色)ctx.stroke(); // 画小圆要重新开始路径ctx.beginPath();// 小圆半径设置为大圆半径的1/3ctx.arc(item.x, item.y, this.circleWidth / 2 / 3, 0, Math.PI * 2);ctx.closePath();// 蓝色小圆ctx.fillStyle = "#217bfb";ctx.fill();// 从第二个圆开始画一条线连接前后两个圆if (index > 0) {ctx.beginPath();ctx.moveTo(this.hitPoints[index - 1].x, this.hitPoints[index - 1].y);ctx.lineTo(item.x, item.y);ctx.strokeStyle = "#217bfb";ctx.stroke();}});
}
看看最终效果
还可以再优化的点
- 目前的绘制效果有点模糊
❝ 因为 canvas 不是矢量图,而是像图片一样是位图模式的。高 dpi 显示设备意味着每平方英寸有更多的像素。也就是说二倍屏,浏览器就会以 2 个像素点的宽度来渲染一个像素,该 canvas 在 Retina 屏幕下相当于占据了2倍的空间,相当于图片被放大了一倍,因此绘制出来的图片文字等会变模糊。 ❞
解决canvas模糊的问题
- 在还没有滑到任何一个小圆内时,页面上没有任何表现,可以加一个跟手的操作,像这样,但是要解决边移动边渲染的性能问题。
有兴趣的可以去实现一下。