记录--[vue3] 用 canvas 搞一个滑动刻度尺
这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
背景
去年做的小程序有一个选择克数的功能,本想着随便搞个数字输入框就完事了,结果产品搞来个app,人家是滑动尺子选的,没辙了,只能硬着头皮做了。
思路
-
搞一个横着排的div,然后里面塞很多很多小div,当做格子,格子弄一个左边框当做格子线,然后外面的父div设置左右滑动,然后监听div的滑动距离,除以格子宽度,就能得到刻度了。 优点:实现简单 缺点:性能极差,我是把尺子放在弹窗里的,一但刻度尺最大值变大了,就得生成好多dom,直接卡半天才能弹起窗来。
-
优化第一种思路,把第一种思路里面的小格子,换成canvas实现,上来先给canvas设置宽度,撑起来外面的div,然后就在画布上画上刻度就ok了,然后还是监听父div的滑动距离,然后计算刻度。 优点:弹窗快了 缺点:监听父div的滑动距离使得当前刻度获取不及时,划得快时只有停下来时才会显示准确,当然思路1也有这个问题
-
既然监听系统的滑动不好使,那就自己搞一个滑动。思路是给尺子最左边设置一个基础值,然后尺子在基础值上开始从左向右画刻度。监听手指滑动canvas事件,从右向左滑就增加基础值,从左向右滑就减少基础值,达到模拟滑动的效果。
效果
实现
开始做之前分析一下这个东西的难点,第一个是要做到像系统滑动级别的丝滑,另一个就是要做到滑动惯性(着急看全部代码的直接跳最后)
1. 划刻度线
- 声明全局变量
/** 每十个格子就写一次刻度数字*/ const divider = 10; /** 隔十个像素就画一个刻度线*/ const itemWidth = 10; /** 画刻度线的起始y坐标*/ const startY = 0; /** 尺子的最小值*/ const min = 0 ; /** 尺子的最大值*/ const max = 100; let leftMin; let leftMax; /** 是否可以惯性滑动 */ let enableInertiaMove = true; // 手指按下时的时间 // let startTime = 0; /** 手指按下时的x坐标,用来比较本次滑动的方向和距离,加在currentCanvasLocation上,就能让尺子移动了*/ let touchStartX = 0; /* 手指按下时,当前 currentCanvasLocation 的值 */ let startValue = 0; /** * 尺子最核心的值,单位为像素,这个值记录了当前手指总共滑动了多少像素距离和方向 * 如果把尺子最低刻度理解为1厘米的话,那么这个就是1毫米。 */ let currentCanvasLocation = 10; // let timer = 0; /** 画布元素 canvas = document.getElementById('test-canvas');*/ let canvas; /** 画布的宽 */ let canvasWidth; /* 画布的高 */ let canvasHeight; /* 画布context,通过操作ctx来画内容 */ let ctx; /* 画布左侧到画布中间格子的数量,加上这个偏移值就能符合视觉的尺子当前值 */ let numberOffset = 0; /** 手指抬起之前的滑动距离,用来发起惯性滑动*/ let lastScrollDistacne = 0; /** 手指最后抬起之前接触的x坐标*/ let lastTouchX = 0;
- 初始化canvas
/* 初始化 Canvas */ const initCanvas = () => { const ruleContainer = document.getElementById('rule-container'); canvas = document.getElementById('test-canvas'); // 这里不要用css设置canvas的宽高,不然会出现绘制模糊的情况 canvas.width = ruleContainer.clientWidth; canvas.height = ruleContainer.clientHeight; ctx = canvas.getContext('2d', { alpha: false }); // 计算屏幕能放下的尺子格数 const screenCount = parseInt((canvas.clientWidth / itemWidth).toFixed(0)) // 计算尺子读数需要的偏移刻度数 numberOffset = parseInt((screenCount / 2).toFixed(0)) ; leftMin = min - numberOffset; leftMax = max - numberOffset; // 保存一下宽高 canvasWidth = canvas.clientWidth; canvasHeight = canvas.clientHeight; // 设置字体 ctx.font = "14px Arial"; // 初始化完成后渲染一下 // 这个方法是将canavs的绘制时机交给系统来控制 // 也可以换成使用 setInterval 实现,要达到一秒60帧的流畅体验,绘制间隔设置成16ms就可以了 window.requestAnimationFrame(draw); }
- 绘制尺子
const draw = () => { // 每次绘制之前先要清空画布 // 设置笔触颜色为白色,每次绘制之前,先把画布用白色清空 ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.fillRect(0, 0, canvasWidth, canvasHeight); ctx.closePath(); // 清空完画布,再把笔触设置成黑色 ctx.fillStyle = "#000000"; // 这里把 currentCanvasLocation 末尾的像素值取出,设置一个滑动时的偏差 let offset: number; // 取当前的位移量的最后一位 const str = currentCanvasLocation.toString(); const lastNumber = Number(str.charAt(str.length - 1)); // currentCanvasLocation 大于和小于0时有不同的取值方式 if (currentCanvasLocation > 0) { offset = itemWidth - lastNumber; } else if (currentCanvasLocation < 0) { offset = lastNumber; // 因为这里是直接将lastNumber赋值给offset,而不是10-lastNumber,所以为出现没有0 的情况,会出现 9 之后直接到1,然后闪一下的情况 // 所以需要手动判断为0时设置为10 if (offset === 0) { offset = itemWidth; } }else{ offset = 0; } // for循环绘制尺子刻度 // 从滑动偏差开始,每次增加 itemWidth 个刻度 /** * 基于 currentCanvasLocation 绘制, * currentCanvasLocation 就是当前canvas的起始像素值, * 可以自定义几个像素值为一个基本刻度,这里我设置成了10, * 一般也都是10 * */ // i+=itemWidth 每隔 itemWidth 个像素划一个刻度 for (let i = offset; i < canvasWidth; i+=itemWidth) { ctx.moveTo(i, startY); // 开头偏移的像素 const scaleNumber = i+currentCanvasLocation; // 只绘制在尺子数值范围内的 if(canDraw(scaleNumber)===0){ if (scaleNumber % (divider*itemWidth) === 0) { // 每个分割线的时候写一个刻度值 const metrics = ctx.measureText(i); const textX = (i - metrics.width / 2).toFixed(2); ctx.fillText(scaleNumber/itemWidth, textX, 45); ctx.lineTo(i, 30); } else { ctx.lineTo(i, 10); } } } ctx.stroke(); } /** * 判断是否可以绘制 * 根据当前的x值来判断, * 小于最小值或大于最大值都不绘制 **/ const canDraw = (x:number): number => { const currentNumber = Math.floor(x / itemWidth); if (currentNumber >= min && currentNumber <= max) { return 0; } return -1; }
2. 处理滑动
写完了draw()
方法,其实处理滑动就很容易了,只需要根据手指滑动的方向和距离,对currentCanvasLocation
做加减操作就完事了
这里我分了三步,对应手指按下到抬起的三个事件
ontouchstart
手指按下,在这里记录下按下时的x
坐标,方便一会计算手指滑动的方向跟距离,清除之前的惯性滑动ontouchend
手指抬起,这里发起惯性滑动事件ontouchmove
手指滑动,手指动一下都会回调这个事件,所以记下每次的x
坐标,跟ontouchstart
时记下的x
做比较,然后对currentCanvasLocation
做加减,同时调用draw()
方法,进行重绘
- 监听手指按下事件
(ontouchstart)
/* @touchstart="canvasTouchStart" 手指按下事件 */ const canvasTouchStart = (e) => { // 拿到手指按下时的横坐标 touchStartX = e.changedTouches[0].clientX; // 记下 startValue = currentCanvasLocation; // 清除之前的惯性滑动效果 enableInertiaMove = false; }
- 手指抬起
(ontouchend)
const canvasTouchEnd = (e) => { // 直接用最后一次滑动的距离来当做速度 enableInertiaMove = true; ease(lastScrollDistacne); } const ease = (target) => { if (!enableInertiaMove) { return; } if (target * canScroll(currentCanvasLocation) > 0) { return; } target *= 0.9; if (Math.abs(target) < 1 || target * canScroll(currentCanvasLocation) > 0) { return } currentCanvasLocation += Math.floor(target); window.requestAnimationFrame(()=>{ ease(target) draw() }); }
- 手指滑动
(ontouchmove)
const canvasTouchMove = (e): void => { // 拿到手指当前的横坐标 const touchClientX = e.targetTouches[0].clientX; // 用当前横坐标减去 手指按下时记录的横坐标(ontouchstart),得到一个差值 const moveX = Math.floor(touchStartX - touchClientX); // 如果超出边界则不允许滑动 if (moveX * canScroll(currentCanvasLocation) > 0) { return; } // 将这个插值加在 currentCanvasLocation 上面,实现滑动 cursorMove(moveX); // 这里使用倒数第二次手指触摸位置 减去最后一次手指触摸位置,得到一个差值 // 这个差值可以理解为手指抬起前单位时间内滑动的距离,即滑动速度 // 值越大速度越快,正反则代表方向 lastScrollDistacne = lastTouchX - touchClientX; lastTouchX = touchClientX; } const cursorMove = (value) => { currentCanvasLocation = startValue + value; // 重绘画布 window.requestAnimationFrame(draw); } /** * 这里使用 1、-1、0 来标志当前尺子的状态 * 当为0时表示可以滑动,1和-1则不行 * 原理: * 滑动时(包括惯性滑动),向右滑,手指从右往左,currentCanvasLocation 加一个正数, * 向左划,手指从左往右,currentCanvasLocation加一个负数。 * 判断是否可以滑动时,使用如下代码: * if(value * canScroll() >0){ * return; * } * 如果一直向左划,划到最小值时,再向左划,value为负数,负负的正,此时被return则不能继续滑动 * 如果一直向右划,划到最大值,再向右划,value为正数,canScroll()为1,此时也会相乘大于0,被return * */ const canScroll = (x:number): number => { const currentNumber = Math.floor(x / itemWidth); if (currentNumber <= leftMin) { return -1; }else if (currentNumber >= leftMax) { return 1; }else{ return 0; } }
到此位置,尺子的核心代码分析就完啦,下面就是全部代码
<template> <div class="home col"> {{ ruleNumber }} <div id="rule-container" class="rule_container"> <span class="rule_cursor"></span> <canvas id="test-canvas" width="300" height="200" @touchmove="canvasTouchMove" @touchend="canvasTouchEnd" @touchstart="canvasTouchStart" ></canvas> </div> </div> </template> <script lang="ts"> import { defineComponent, reactive, toRefs, onMounted, nextTick } from 'vue'; export default defineComponent({ name: 'Home', setup() { const state = reactive({ ruleNumber: 0 }) /** 每十个格子就写一次刻度数字*/ const divider = 10; /** 隔十个像素就画一个刻度线*/ const itemWidth = 10; /** 画刻度线的起始y坐标*/ const startY = 0; /** 尺子的最小值*/ const min = 0 ; /** 尺子的最大值*/ const max = 100; let leftMin; let leftMax; /** 惯性滑动用到的计时器 */ let enableInertiaMove = true; // 手指按下时的时间 // let startTime = 0; /** 手指按下时的x坐标,用来比较本次滑动的方向和距离,加在currentCanvasLocation上,就能让尺子移动了*/ let touchStartX = 0; /* 手指按下时,当前 currentCanvasLocation 的值 */ let startValue = 0; /** * 尺子最核心的值,单位为像素,这个值记录了当前手指总共滑动了多少像素距离和方向 * 如果把尺子最低刻度理解为1厘米的话,那么这个就是1毫米。 */ let currentCanvasLocation = 10; // let timer = 0; /** 画布元素 canvas = document.getElementById('test-canvas');*/ let canvas; /** 画布的宽 */ let canvasWidth; /* 画布的高 */ let canvasHeight; /* 画布context,通过操作ctx来画内容 */ let ctx; /* 画布左侧到画布中间格子的数量,加上这个偏移值就能符合视觉的尺子当前值 */ let numberOffset = 0; /** 手指抬起之前的滑动距离,用来发起惯性滑动*/ let lastScrollDistacne = 0; /** 手指最后抬起之前接触的x坐标*/ let lastTouchX = 0; /* 初始化 Canvas */ const initCanvas = () => { const ruleContainer = document.getElementById('rule-container'); canvas = document.getElementById('test-canvas'); canvas.width = ruleContainer.clientWidth; canvas.height = ruleContainer.clientHeight; ctx = canvas.getContext('2d', { alpha: false }); // 计算屏幕能放下的尺子格数 const screenCount = parseInt((canvas.clientWidth / itemWidth).toFixed(0)) // 计算尺子读数需要的偏移刻度数 numberOffset = parseInt((screenCount / 2).toFixed(0)) ; leftMin = min - numberOffset; leftMax = max - numberOffset; // 设置宽高 canvasWidth = canvas.clientWidth; canvasHeight = canvas.clientHeight; // 设置字体 ctx.font = "14px Arial"; // 初始化完成后渲染一下 window.requestAnimationFrame(draw); } const draw = () => { // 设置笔触颜色为白色,每次绘制之前,先把画布用白色清空 ctx.fillStyle = "#ffffff"; ctx.beginPath(); ctx.fillRect(0, 0, canvasWidth, canvasHeight); ctx.closePath(); // 清空完画布,再把笔触设置成黑色 ctx.fillStyle = "#000000"; // 尺子的最小刻度为10个像素,for循环渲染尺度时会以 start 为准,所以每次会出现每次滑动时就一格一格的跳动,不够顺滑 // 这里把 currentCanvasLocation 末尾的像素值取回来,让滑动更加顺滑 let offset: number; // 取当前的位移量的最后一位 const str = currentCanvasLocation.toString(); const lastNumber = Number(str.charAt(str.length - 1)); // currentCanvasLocation 大于和小于0时有不同的取值方式 if (currentCanvasLocation > 0) { offset = itemWidth - lastNumber; } else if (currentCanvasLocation < 0) { offset = lastNumber; // 因为这里是直接将lastNumber赋值给offset,而不是10-lastNumber,所以为出现没有0 的情况,会出现 9 之后直接到1,然后闪一下的情况 // 所以需要手动判断为0时设置为10 if (offset === 0) { offset = itemWidth; } }else{ offset = 0; } // for循环绘制尺子刻度 for (let i = offset; i < canvasWidth; i+=itemWidth) { ctx.moveTo(i, startY); // 开头偏移的像素 const scaleNumber = i+currentCanvasLocation; // 只绘制在尺子数值范围内的 if(canDraw(scaleNumber)===0){ if (scaleNumber % (divider*itemWidth) === 0) { const metrics = ctx.measureText(i); const textX = (i - metrics.width / 2).toFixed(2); ctx.fillText(scaleNumber/itemWidth, textX, 45); ctx.lineTo(i, 30); } else { ctx.lineTo(i, 10); } } } ctx.stroke(); // 绘制完成,加上数量 nextTick(() => { state.ruleNumber = Math.floor(currentCanvasLocation / itemWidth) + numberOffset; }) } onMounted(() => { initCanvas(); }) const canDraw = (x:number): number => { const currentNumber = Math.floor(x / itemWidth); if (currentNumber >= min && currentNumber <= max) { return 0; } return -1; } /** * 这里使用 1、-1、0 来标志当前尺子的状态 * 当为0时表示可以滑动,1和-1则不行 * 原理: * 滑动时(包括惯性滑动),向右滑,手指从右往左,currentCanvasLocation 加一个正数, * 向左划,手指从左往右,currentCanvasLocation加一个负数。 * 判断是否可以滑动时,使用如下代码: * if(value * canScroll() >0){ * return; * } * 如果一直向左划,划到最小值时,再向左划,value为负数,负负的正,此时被return则不能继续滑动 * 如果一直向右划,划到最大值,再向右划,value为正数,canScroll()为1,此时也会相乘大于0,被return * */ const canScroll = (x:number): number => { const currentNumber = Math.floor(x / itemWidth); if (currentNumber <= leftMin) { return -1; }else if (currentNumber >= leftMax) { return 1; }else{ return 0; } } /* 手指按下事件 */ const canvasTouchStart = (e) => { touchStartX = e.changedTouches[0].clientX; startValue = currentCanvasLocation; // 清除之前的惯性滑动 enableInertiaMove = false; } const canvasTouchMove = (e): void => { const touchClientX = e.targetTouches[0].clientX; const moveX = Math.floor(touchStartX - touchClientX); lastScrollDistacne = lastTouchX - touchClientX; lastTouchX = touchClientX; if (moveX * canScroll(currentCanvasLocation) > 0) { return; } cursorMove(moveX) } const cursorMove = (value) => { currentCanvasLocation = startValue + value; window.requestAnimationFrame(draw); } const canvasTouchEnd = (e) => { // 直接用最后一次滑动的距离来当做速度 enableInertiaMove = true; ease(lastScrollDistacne); } const ease = (target) => { if (!enableInertiaMove) { return; } if (target * canScroll(currentCanvasLocation) > 0) { return; } target *= 0.9; if (Math.abs(target) < 1 || target * canScroll(currentCanvasLocation) > 0) { return } currentCanvasLocation += Math.floor(target); window.requestAnimationFrame(()=>{ ease(target) draw() }); } return { ...toRefs(state), canvasTouchMove, canvasTouchEnd, canvasTouchStart } } }); </script> <style scoped> .rule_container { /* width: 100%; */ position: relative; } .rule_cursor { position: absolute; top: 0; width: 1%; left: 49.5%; height: 40px; background-color: blue; } </style>