记录--[vue3] 用 canvas 搞一个滑动刻度尺

这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助

背景

去年做的小程序有一个选择克数的功能,本想着随便搞个数字输入框就完事了,结果产品搞来个app,人家是滑动尺子选的,没辙了,只能硬着头皮做了。

思路

  1. 搞一个横着排的div,然后里面塞很多很多小div,当做格子,格子弄一个左边框当做格子线,然后外面的父div设置左右滑动,然后监听div的滑动距离,除以格子宽度,就能得到刻度了。 优点:实现简单 缺点:性能极差,我是把尺子放在弹窗里的,一但刻度尺最大值变大了,就得生成好多dom,直接卡半天才能弹起窗来。

  2. 优化第一种思路,把第一种思路里面的小格子,换成canvas实现,上来先给canvas设置宽度,撑起来外面的div,然后就在画布上画上刻度就ok了,然后还是监听父div的滑动距离,然后计算刻度。 优点:弹窗快了 缺点:监听父div的滑动距离使得当前刻度获取不及时,划得快时只有停下来时才会显示准确,当然思路1也有这个问题

  3. 既然监听系统的滑动不好使,那就自己搞一个滑动。思路是给尺子最左边设置一个基础值,然后尺子在基础值上开始从左向右画刻度。监听手指滑动canvas事件,从右向左滑就增加基础值,从左向右滑就减少基础值,达到模拟滑动的效果。

效果

test3.gif

实现

开始做之前分析一下这个东西的难点,第一个是要做到像系统滑动级别的丝滑,另一个就是要做到滑动惯性(着急看全部代码的直接跳最后)

1. 划刻度线

  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;
  1. 初始化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);
    }
  1. 绘制尺子
    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 做加减操作就完事了

这里我分了三步,对应手指按下到抬起的三个事件

  1. ontouchstart 手指按下,在这里记录下按下时的x坐标,方便一会计算手指滑动的方向跟距离,清除之前的惯性滑动
  2. ontouchend 手指抬起,这里发起惯性滑动事件
  3. 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>

本文转载于:

https://juejin.cn/post/6962152799601688613

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

 

posted @ 2022-12-09 17:57  林恒  阅读(1852)  评论(0编辑  收藏  举报