现在很多app,在一些隐私页面,往往都会加入二次验证,例如银行app、支付宝理财和我的页面,一般会有「九宫格密码」和指纹密码。
今天我们用canvas来写一个九宫格手势密码锁,大概就是下面这样。
思路
- 准备一个正方形画布
- 找到9个小圆圈的圆心坐标(位置自己定,布局合理即可)
- 绘制圆圈
- 监听手势并连接小圆圈
实现
第一步:先初始化一个空白画布
js
<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
js
ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);
- x:圆心的 x 轴坐标。
- y:圆心的 y 轴坐标。
- radius:圆的半径
- startAngle:圆弧的起始点,x 轴方向开始计算,单位以弧度表示。
- endAngle:圆弧的终点,单位以弧度表示。
- anticlockwise(可选):可选的Boolean值,如果为 true,逆时针绘制圆弧,反之,顺时针绘制。
找圆心坐标和半径
定义函数
js
// 计算圆的坐标
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是为了解决浮点数计算丢失精度问题的
js
// 浮点数计算,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中调一下
js
init() {
// ...前面的省略了
// 计算九个圆圈的圆心的坐标和直径大小
const { points, circleWidth } = this.calcCirclePos();
// 存起来
this.points = points;
this.circleWidth = circleWidth;
}
绘制小圆
定义画圆函数
js
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个小圆坐标以及圆的半径对比,判断是否滑动到了圆圈内
js
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 {
// 非手机端,逻辑一致,不同的是监听方法不同
}
定义获取触摸坐标的函数
js
getTouchPosition(canvas, event) {
// 获取画布相对于浏览器窗口的位置信息
// 当画布不在浏览器左上角时必须这么计算
const rect = canvas.getBoundingClientRect();
const x = event.pageX - rect.left;
const y = event.pageY - rect.top;
return { x, y };
}
判断是否进入了某个圆圈内
js
// 接收触摸位置的坐标 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坐标数组,将圆环变为蓝色,并在内部画一个小圆填充
js
// 绘制命中后的圆圈样式
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/3
ctx.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倍的空间,相当于图片被放大了一倍,因此绘制出来的图片文字等会变模糊。 ❞
- 在还没有滑到任何一个小圆内时,页面上没有任何表现,可以加一个跟手的操作,像这样,但是要解决边移动边渲染的性能问题。
有兴趣的可以去实现一下。