背景
去年做的小程序有一个选择克数的功能,本想着随便搞个数字输入框就完事了,结果产品搞来个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>
复制代码
其他
写博客经验还是太少了,这次和之前的文章都是直接成品发上来,然后加了点解析注释,甚至之前有的地方练解析都没有,没有正经博客文章那种循序渐进的过程,以后写博客一定要留好每个阶段的代码,做好每个阶段的解读。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END