阅读 1149

采用Canvas绘制一个可配置的刻度(尺)组件【演戏演全套】

前言

本次分享一个自己采用Canvas绘制一个可配置的刻度(尺)组件。 组件应用:主要常用于移动端数值、金额等的滑动选择,增强用户交互体验。

文章涉及开发思路、代码拆解、问题解决、刻度组件库封装等多方面的知识。有需要的童鞋可以适当参考,开发出更绚丽、更完善的组件。

欢迎到github地址,多多start:github.com/now1then/ca…

也欢迎访问 演示地址体验功能:rnlvwyx.cn:3333/#/demo

功能分析说明

刻度组件 demo,效果图:

demo2.gif
从效果图上分析,有处于中间的不移动的中心线;间隔的刻度长短效果及刻度尺每10间隔的刻度值显示,且刻度尺可左右移动选取值;移动到左侧最小值及右侧最大值后,刻度不能继续移动;支持根据传入的值,响应刻度变化,以及签名等...诸多优化项。

组件开发将完成以下功能:

  • 采用canvas绘制组件,解决移动端绘制模糊问题,
  • 支持刻度尺基本参数配置传入,
  • 监听滑动事件,滑动时实时输出刻度值,同时支持根据外部值动态设置刻度,
  • 支持平滑/缓动滑动、实时绘制刻度,
  • 兼容移动端/pc端滑动,

组件使用

引入scale.js文件:
import scale from './scale.js'; // 引入scale.js文件
// or
npm install canvas-scale
import scale from 'canvas-scale';
复制代码

scale模块对外暴露一个init()初始化方法; scale.init()函数 :

  • 第一个参数为可通过document.querySelector()获取到的HTML节点;
  • 第二个参数为需要重置的配置项;
  • 第三个参数传入刻度变更时的回调函数,可通过该回调函数获取最新刻度值;
  • 返回一个实例对象,对外暴露一些操作方法。
/**
 * scale 刻度函数
 * @param {String} el  html节点
 * @param {Object} options  配置信息
 * @param {Function} callBack 刻度变更回调函数
 * @returns { Object}
 */
// 绘制刻度尺
const myScale = scale.init('#myScale', {height: 50, start: 10000, end: 2000},callBack);
function callBack(value) {
  console.log(value);
}
复制代码
目前返回的实例对象暴露的方法有:
  • update(value):传入最新的刻度值,更新画布显示。value:最新刻度值
  • clear():清除当前画布。
  • resize(option):重置画布,可传入最新需要重置的配置信息。option:刻度配置

myScale.update(1000); // 更新刻度值
myScale.clear();  // 清除画布
myScale.resize(); // 重置刻度画布
复制代码

开发

不熟悉canvas api的童鞋,可以访问 Canvas API中文网 学习。 我自己也总结归纳了一遍关于Canvas开发及常见问题的文章:

这里讲解一下开发思路,主要拆分为以下几个步骤:

  1. 配置项及功能:
  2. canvas绘制:
  3. 绘制中心线
  4. 绘制整个刻度尺
  5. 根据实际刻度值剪切刻度尺
  6. 绘制签名及背景
  7. 交互:
  8. 增加监听滑动事件,实时绘制刻度画布
  9. 数值与刻度联动变更
  10. 各种边界异常情况处理

配置项

组件支持的配置项,目前为以下配置:

// 默认配置
const default_conf = {
  // width: '',  // 不支持设置,取容器宽度
  height: 50, // 画布高度
  start: 1000, // 刻度开始值
  end: 10000, // 刻度结束值
  // def: 100, // 中心线停留位置 刻度值
  unit: 10, // 刻度间隔 'px'
  capacity: 100, // 刻度容量值
  background: '#fff', // 设置颜色则背景为对应颜色虚幻效果,不设置默认为全白。
  lineColor: '#087af7', // 中心线颜色
  openUnitChange: true, // 是否开启间隔刻度变更
  sign: '@nowThen', // 签名,传入空不显示签名
  fontColor: '#68ca68', // 刻度数值颜色, 刻度线颜色暂未提供设置
  fontSize: '16px SimSun, Songti SC', // 刻度数值 字体样式
};
复制代码

通过scale.init()的第二个参数传入配置项,否则将使用以上默认配置项。

canvas绘制

根据传入的容器绘制canvas:

  // 根据传入的容器绘制canvas
  const container = document.querySelector(el);
  container.appendChild(canvas);
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
复制代码

绘制中心线

中间线一直停留在画布中间,标识当前刻度值。

// 绘制中心线
function drawMidLine() {
  const mid_x = Math.floor(config.width / 2);
  ctx.beginPath();
  ctx.fillStyle = config.lineColor;
  ctx.fillRect(mid_x - 1, 0, 2, config.height);
  ctx.stroke();
  ctx.moveTo(mid_x, 8);
  ctx.lineTo(mid_x - 5, 2);
  ctx.lineTo(mid_x - 5, 0);
  ctx.lineTo(mid_x + 5, 0);
  ctx.lineTo(mid_x + 5, 2);
  ctx.fill();
  ctx.moveTo(mid_x, config.height - 8);
  ctx.lineTo(mid_x - 5, config.height - 2);
  ctx.lineTo(mid_x - 5, config.height);
  ctx.lineTo(mid_x + 5, config.height);
  ctx.lineTo(mid_x + 5, config.height - 2);
  ctx.fill();
  ctx.closePath();
}
复制代码

设置签名及背景色

背景色config.background: 背景为传入的颜色值,且额外为画布 营造一些画布两边刻度线虚幻的效果。 传入空,则整个画布为透明色,且无两端虚化效果。

签名config.sign显示在画布右上角,传入空值就不显示签名。

  // 设置签名及背景
  function drawSign() {
    // 背景 虚化效果、、
    if (config.background) {
      ctx.beginPath();
      var gradient1 = ctx.createLinearGradient(0, 0, config.width, 0);
      gradient1.addColorStop(0, 'rgba(255, 255, 255, 0.95)');
      gradient1.addColorStop(0.45, 'rgba(255, 255, 255, 0)');
      gradient1.addColorStop(0.55, 'rgba(255, 255, 255, 0)');
      gradient1.addColorStop(1, 'rgba(255, 255, 255, 0.95)');
      ctx.fillStyle = gradient1;
      ctx.fillRect(0, 0, config.width, config.height);
      ctx.closePath();
    }
          
    // 签名
    if (config.sign) {
      ctx.beginPath();
      ctx.font = '10px Arial';
      var gradient = ctx.createLinearGradient(config.width, 0, config.width - 50, 0);
      gradient.addColorStop(0, 'rgba(255, 0, 0, 0.3)');
      gradient.addColorStop(1, 'rgba(0, 128, 0, 0.3)');
      ctx.fillStyle = gradient;
      ctx.textAlign = 'right';
      ctx.fillText(config.sign, config.width - 10, 10);
      ctx.closePath();
      ctx.fillStyle = 'transparent';
    }
  }

// 在绘制刻度尺时,设置背景值
ctx_bg.fillStyle = _config.background || 'transparent'; // 背景色
复制代码

绘制刻度尺

这里想到了两个可行思路:

  1. 提前绘制好整个刻度尺画布,在滑动时,根据参数截取刻度尺画布的一部分区域绘制到可视区域中。
  2. 根据当前刻度值、滑动距离等参数,实时绘制画布可视区域的刻度分布。

在实际开发时,这两种方案都尝试并实现了。也暴露了一些问题:

第一种方案:

步骤分为以下:

  • 根据传入的配置,设置样式;计算刻度尺画布的宽高;
  • 绘制整个刻度尺底线;
  • 计算刻度个数,并依次绘制每个刻度,刻度宽度1px,普通刻度1/5 * height高,每隔5刻度1/3 * height高,每隔10刻度 1/2 * height高并绘制刻度值;
  • 计算截取刻度尺画布的开始位置;
  • 然后通过context.getImageData(sx, sy, sWidth, sHeight);截取刻度尺图像区域,通过context.putImageData(imageData, 0, 0);将上面截取的ImageData对象的数据绘制到主画布上。

使用context.drawImage()也能达到相同效果。

在滑动时,根据滑动的位置,截取对应的刻度尺区域图形,绘制在主画布上。

  // 创建新的刻度画布 作为底层图片
  const canvas_bg = document.createElement('canvas');
  const ctx_bg = canvas_bg.getContext('2d');

  // 绘制刻度尺
  function drawScale() {
    const mid = config.end - config.start + 1; // 取值范围

    const scale_len = Math.ceil(mid / config.capacity); // 刻度条数
    const space = Math.floor(config.width / 2); //左右两边间隙,根据该值计算整数倍刻度值画线
    const beginNum = Math.ceil(config.start / config.capacity) * config.capacity;
    const st = (Math.ceil(config.start / config.capacity) - config.start / config.capacity) * config.unit;

    // 设置canvas_bg宽高
    canvas_bg.width = (config.unit * (scale_len - 1) + config.width) * dpr;
    canvas_bg.height = config.height * dpr;
    ctx_bg.scale(dpr, dpr);

    ctx_bg.beginPath();
    ctx_bg.fillStyle = config.background || 'transparent'; // 背景色
    ctx_bg.fillRect(0, 0, canvas_bg.width, config.height);
    ctx_bg.closePath();
    // 底线
    ctx_bg.beginPath();
    ctx_bg.moveTo(0, config.height);
    ctx_bg.lineTo(canvas_bg.width, config.height);
    ctx_bg.strokeStyle = config.scaleLineColor || '#9E9E9E';
    ctx_bg.lineWidth = 1;
    ctx_bg.stroke();
    ctx_bg.closePath();

    // 绘制刻度线
    for (let i = 0; i < scale_len; i++) {
      ctx_bg.beginPath();
      ctx_bg.strokeStyle = config.scaleLineColor || "#9E9E9E";
      ctx_bg.font = config.fontSize;
      ctx_bg.fillStyle = config.fontColor;
      ctx_bg.textAlign = 'center';
      ctx_bg.shadowBlur = 0;

      const curPoint = i * config.unit + space + st;
      const curNum = i * config.capacity + beginNum;
      if (curNum % (config.capacity * 10) === 0) {
        ctx_bg.moveTo(curPoint, (config.height * 1) / 2);
        ctx_bg.strokeStyle = config.scaleLineColor || "#666";
        ctx_bg.shadowColor = '#9e9e9e';
        ctx_bg.shadowBlur = 1;
        ctx_bg.fillText(
          curNum,
          curPoint,
          (config.height * 1) / 3
        );
      } else if (curNum % (config.capacity * 5) === 0) {
        ctx_bg.moveTo(curPoint, (config.height * 2) / 3);
        ctx_bg.strokeStyle = config.scaleLineColor || "#888";
        if (scale_len <= 10) {
          ctx_bg.font = '12px Helvetica, Tahoma, Arial';
          ctx_bg.fillText(
            curNum,
            curPoint,
            (config.height * 1) / 2
          );
        }
      } else {
        ctx_bg.moveTo(curPoint, (config.height * 4) / 5);
        if (i === 0 || i === scale_len - 1) {
          ctx_bg.font = '12px Helvetica, Tahoma, Arial';
          ctx_bg.fillText(
            curNum,
            curPoint,
            (config.height * 2) / 3
          );
        }
      }
      ctx_bg.lineTo(curPoint, config.height);
      ctx_bg.stroke();
      ctx_bg.closePath();
    }

    point_x = (config.def - config.start) / config.capacity * config.unit; //初始化开始位置
    const imageData = ctx_bg.getImageData(point_x * dpr, 0, config.width * dpr, config.height * dpr)
    ctx.putImageData(imageData, 0, 0);
  }
复制代码
第二种方案:

步骤分为以下:

  • 绘制整个刻度尺底线;
  • 以中心刻度为基准,计算最左侧的刻度值;
  • 计算画布区域可绘制的刻度线个数及第一个刻度线的位置及刻度数值;
  • 依次绘制每个刻度,刻度宽度1px,普通刻度1/5 * height高,每隔5刻度1/3 * height高,每隔10刻度 1/2 * height高并绘制刻度值;
  • 然后context.drawImage()绘制图像到主画布区域;

在滑动时,实时计算当前中间刻度值,并依据上面的绘制步骤,重绘整个画布。

  // 创建新的刻度画布 作为底层图片
  const canvas_bg = document.createElement('canvas');
  const ctx_bg = canvas_bg.getContext('2d');

  // 绘制刻度尺
  function drawScale() {
    // 设置canvas_bg宽高
    canvas_bg.width = (config.unit * (scale_len - 1) + config.width) * dpr;
    canvas_bg.height = config.height * dpr;
    ctx_bg.scale(dpr, dpr);

    // 以中点刻度为基准,获取最左侧刻度值
    let begin_num = current_def - (config.width / 2) * (config.capacity / config.unit);
    let cur_x = 0;
    let cur_num = 0;
    const scale_len = Math.ceil((config.width + 1) / config.unit); // 刻度条数
    const real_len = Math.ceil((config.end - config.start + 1) / config.capacity); // 实际可绘制的刻度条数

    ctx_bg.fillStyle = config.background || 'transparent'; // 背景色
    ctx_bg.fillRect(0, 0, config.width, config.height);
    ctx_bg.closePath();
    // 底线
    ctx_bg.beginPath();
    ctx_bg.moveTo(0, config.height);
    ctx_bg.lineTo(config.width, config.height);
    ctx_bg.strokeStyle = config.scaleLineColor || '#9E9E9E';
    ctx_bg.lineWidth = 1;
    ctx_bg.stroke();
    ctx_bg.closePath();

    let space_num = Math.ceil(begin_num / config.capacity) * config.capacity - begin_num;
    let space_x = space_num * (config.unit / config.capacity);

    // 绘制刻度线
    for (let i = 0; i < scale_len; i++) {
      cur_num = (Math.ceil(begin_num / config.capacity) + i) * config.capacity;
      if (cur_num < config.start) {
        continue;
      } else if (cur_num > config.end) {
        break;
      }

      ctx_bg.beginPath();
      ctx_bg.strokeStyle = config.scaleLineColor || "#9E9E9E";
      ctx_bg.font = config.fontSize;
      ctx_bg.fillStyle = config.fontColor;
      ctx_bg.textAlign = 'center';
      ctx_bg.shadowBlur = 0;
      cur_x = space_x + i * config.unit;

      if (cur_num % (config.capacity * 10) === 0) {
        ctx_bg.moveTo(cur_x, (config.height * 1) / 2);
        ctx_bg.strokeStyle = config.scaleLineColor || "#666";
        ctx_bg.shadowColor = '#9e9e9e';
        ctx_bg.shadowBlur = 1;
        ctx_bg.fillText(
          cur_num,
          cur_x,
          (config.height * 1) / 3
        );
      } else if (cur_num % (config.capacity * 5) === 0) {
        ctx_bg.moveTo(cur_x, (config.height * 2) / 3);
        ctx_bg.strokeStyle = config.scaleLineColor || "#888";
        if (real_len <= 10) {
          ctx_bg.font = '12px Helvetica, Tahoma, Arial';
          ctx_bg.fillText(
            cur_num,
            cur_x,
            (config.height * 1) / 2
          );
        }
      } else {
        ctx_bg.moveTo(cur_x, (config.height * 4) / 5);
      }
      ctx_bg.lineTo(cur_x, config.height);
      ctx_bg.stroke();
      ctx_bg.closePath();
    }
    ctx.drawImage(canvas_bg, 0, 0, config.width * dpr, config.height * dpr, 0, 0, config.width, config.height);  
  }
复制代码
总结

第一种方案: 在实现难度上更简单;这个刻度尺画布只需绘制一次,滑动时无需重绘刻度尺画布;也更能直观体现刻度移动, 但在绘制刻度区间跨度大时,性能不好,且canvas画布尺寸过大,会出现绘制空白的问题。 第二种方案: 比较难定位可视区域刻度尺的初始值、结束值,且一滑动,整个画布都重新计算每个绘制点。 咋一看实现更麻烦,滑动时整个画布都得实时绘制,但相比于第一种方案的致命缺陷,效果、性能及兼容性更佳。

这样,初始的刻度尺样式就生成了。

image.png
接下来就是添加滑动交互效果。

开发过程中遇到的问题(在文章后面有较详细的说明):

  • 移动端canvas绘制模糊问题,canvas插入图像模糊问题;
  • context.drawImage()参数问题;
  • 当canvas绘制尺寸或插入图像尺寸大于某个阈值时,可能会出现绘制空白问题。

滑动事件监听,兼容PC端

监听左右滑动事件,获取每次手指滑动的距离,计算刻度需要移动的距离,并重新绘制canvas画布。

  1. 注册事件兼容移动端和PC端;移动端监听touch事件,PC端监听mouse事件,这里要注意获取当前触碰点的差异处理。

移动端获取当前触摸点X坐标:e.touches[0].pageX;PC端获取鼠标X坐标:e.pageX

  1. 在move事件中,实时绘制,实时更新刻度值,并调用传入的回调函数传回刻度数值;
  2. 处理移动到左右两侧边界的情况,达到设置的最大最小值时,无法继续移动;
  3. 利用 window.requestAnimationFrame()优化渲染频率。
  4. 重新绘制前,需要利用ctx.clearRect()先清空画布再绘制,否则有绘制重叠。
  5. 根据配置项config.openUnitChange决定是否只能间隔刻度移动,比如100、200、300...变更。传入false时,则按实际移动距离变更。

效果如下图:

按刻度移动.gif
非刻度移动.gif
主要代码:

  // 事件交互 (第一种方案)
  function addEvent() {
    let begin_x = 0; // 手指x坐标
    let ifMove = false; // 是否开始交互
    let moveDistance = 0;

    // 注册事件,移动端和PC端
    const hasTouch = 'ontouchstart' in window;
    const startEvent = hasTouch ? 'touchstart' : 'mousedown';
    const moveEvent = hasTouch ? 'touchmove' : 'mousemove';
    const endEvent = hasTouch ? 'touchend' : 'mouseup';
    canvas.addEventListener(startEvent, start);
    canvas.addEventListener(moveEvent, move);
    canvas.addEventListener(endEvent, end);

    function start(e) {
      e.stopPropagation();
      e.preventDefault();
      ifMove = true;
      if (!e.touches) {
        begin_x = e.pageX;
      } else {
        begin_x = e.touches[0].pageX;
      }
    }

    function move(e) {
      e.stopPropagation();
      e.preventDefault();
      const current_x = e.touches ? e.touches[0].pageX : e.pageX;
      if (ifMove) {
        moveDistance = current_x - begin_x;
        begin_x = current_x;
        point_x = point_x - moveDistance; //刻度偏移量
        const space = Math.floor(config.width / 2);
        // 边界值处理
        if (point_x <= 0) {
          point_x = 0;
        } else if (point_x >= canvas_bg.width / dpr - config.width) {
          point_x = canvas_bg.width / dpr - config.width;
        }

        window.requestAnimationFrame(moveDraw)
      }
    }

    function end(e) {
      ifMove = false;
    }
  }

  function moveDraw() {
    let now_x = point_x;
    // 是否刻度移动
    if (config.openUnitChange) {
      const st = ( config.start / config.capacity - Math.floor(config.start / config.capacity)) * config.unit;
      now_x = Math.round(this.point_x / config.unit) * config.unit - st;
    }
    ctx.clearRect(0, 0, config.width, config.height);
    // ctx.drawImage(canvas_bg, now * dpr, 0, config.width * dpr, config.height * dpr, 0, 0, config.width, config.height);
    var imageData = ctx_bg.getImageData(now_x * dpr, 0, config.width * dpr, config.height * dpr)
    ctx.putImageData(imageData, 0, 0)
    drawMidLine();
    drawSign();
    const value = now_x * config.capacity / config.unit + config.start;
    if (typeof callBack === 'function') {
      callBack(Math.round(value));
    } else {
      throw new Error('scale函数的第二个参数,必须为正确的回调函数!')
    }
  }
复制代码

平滑移动,缓动函数

上面的事件交互是第一种方案的代码。未做平滑缓动处理。 在此基础上改成第二种方案的代码,且增加平滑移动,缓动函数采用easeOut,先快后慢。

// easeOut 缓动函数
const slowActionfn = function (t, b, c, d) {
  return c * ((t = t / d - 1) * t * t + 1) + b;
};  

// 事件交互
function addEvent() {
  let begin_x = 0; // 手指x坐标
  let last_x = 0; //上一次x坐标
  let ifMove = false; // 是否开始交互
  let from_def = 0;
  let lastMoveTime = 0;
  let lastMove_x = 0;

  // 注册事件,移动端和PC端
  const hasTouch = 'ontouchstart' in window;
  const startEvent = hasTouch ? 'touchstart' : 'mousedown';
  const moveEvent = hasTouch ? 'touchmove' : 'mousemove';
  const endEvent = hasTouch ? 'touchend' : 'mouseup';
  canvas.addEventListener(startEvent, start);
  canvas.addEventListener(moveEvent, move);
  canvas.addEventListener(endEvent, end);

  function start(e) {
    e.stopPropagation();
    e.preventDefault();
    ifMove = true;
    if (!e.touches) {
      last_x = begin_x = e.pageX;
    } else {
      last_x = begin_x = e.touches[0].pageX;
    }
    lastMove_x = last_x;
    lastMoveTime = e.timeStamp || Date.now();
  }

  function move(e) {
    e.stopPropagation();
    e.preventDefault();
    const current_x = e.touches ? e.touches[0].pageX : e.pageX;
    if (ifMove) {
      move_x = current_x - last_x;
      current_def = current_def - move_x * (config.capacity / config.unit);
      window.requestAnimationFrame(moveDraw);
      last_x = current_x;

      const nowTime = e.timeStamp || Date.now();
      if (nowTime - lastMoveTime > 300) {
        lastMoveTime = nowTime;
        lastMove_x = last_x;
      }
    }
  }


  function end(e) {
    const current_x = e.changedTouches ? e.changedTouches[0].pageX : e.pageX;
    const nowTime = e.timeStamp || Date.now();
    const v = -(current_x - lastMove_x) / (nowTime - lastMoveTime); //最后一段时间手指划动速度

    ifMove = false;
    let t = 0, d = 15;
    if (Math.abs(v) >= 0.3) {
      from_def = current_def;
      step();
    } else {
      if (current_def < config.start) {
        current_def = config.start;
      } else if (current_def > config.end) {
        current_def = config.end;
      }
      if (config.openUnitChange) {
        current_def = Math.round(current_def / config.capacity) * config.capacity;
      }
      moveDraw();
    }

    function step() {
      current_def = slowActionfn(t, from_def, (config.capacity) * v * 50, d);
      if (current_def < config.start) {
        current_def = config.start;
      } else if (current_def > config.end) {
        current_def = config.end;
      }
      if (config.openUnitChange) {
        current_def = Math.round(current_def / config.capacity) * config.capacity;
      }
      moveDraw()
      t++;
      if (t <= d) {
        // 继续运动
        window.requestAnimationFrame(step);
      } else {
        // 结束
      }
    }
  }
}

function moveDraw() {
  ctx.clearRect(0, 0, config.width, config.height);

  drawScale();
  drawMidLine();
  drawSign();

  if (typeof callBack === 'function') {
    callBack(Math.round(current_def));
  } else {
    throw new Error('scale函数的第二个参数,必须为正确的回调函数!')
  }
}
复制代码

边界处理

在绘制过程中,还需要处理各种边界情况:

  1. 对传入的配置项作简单验证提示,比如id是否是有效的dom节点;
  2. 计算并设置默认的中心线位置;
  3. 滑动时,滑到最左侧、最右侧时的临界值处理;
  4. 传入的刻度值不在刻度区间内时的处理;

这些边界情况在组件库代码中都做了简单处理。

常见问题

详情欢迎查看本人另一篇文章-「记录canvas使用及常见问题

移动端绘制canvas模糊问题

现象

如下图:

image.png
上图中,在未做兼容移动端处理时,绘制的canvas刻度组件在iphone 6s机型上,canvas图形和文字模糊失真。兼容处理后图形质量得以保证。 以上刻度组件参考我的另一篇文章,地址:www.yuque.com/nowthen/lon…

原因

关于移动端高清屏DPR、图片模糊、移动端适配等问题,不清楚的童鞋可以参考「关于移动端适配,你必须要知道的」这篇文章,讲的比较详细。这里不再赘述,本文章只处理移动端Canvas模糊问题。 在移动端高清屏幕上,经常会遇到Canvas图形模糊的问题。本质上跟移动端图片模糊问题是一样的。canvas绘制成的图像跟也是位图,在dpr > 1的屏幕上,位图的一个像素可能由多个物理像素来渲染,然而这些物理像素点并不能被准确的分配上对应位图像素的颜色,只能取近似值,所以在dpr > 1的屏幕上就会模糊。 在PC端绘制canvas图形,我们都直接把1个canvas像素直接等于1px的css像素处理,这没有问题,应该目前PC端屏幕dpr都是1。而在dpr > 1的移动端屏幕上就不能直接这样处理。

解决

解决方案当然还是从dpr入手。

  1. 通过window.devicePixelRatio获取当前设备屏幕的dpr;
  2. 首先获取或设置Canvas容器的宽高;
  3. 根据dpr,设置canvas元素的宽高属性;在dpr = 2时相当于扩大画布2倍;
  4. 通过context.scale(dpr, dpr)缩放Canvas画布的坐标系。在dpr = 2时相当于把canvas坐标系也扩大了两倍,这样绘制比例放大了2倍,之后canvas的实际绘制像素就可以按原先的像素值处理。

在渲染到屏幕时,扩大的画布图形又等比例缩放渲染到canvas容器中。从而保证canvas图形的质量。

// 获取dpr
const dpr = window.devicePixelRatio; 
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 获取Canvas容器的宽高
const { width: cssWidth, height: cssHeight } = canvas.getBoundingClientRect();
// 根据dpr,设置Canvas的宽高,使1个canvas像素和1个物理像素相等
canvas.width = dpr * cssWidth;
canvas.height = dpr * cssHeight;
// 根据dpr,设置canvas元素的宽高属性
ctx.scale(dpr,dpr);
复制代码

canvas drawImage()参数问题,移动端图片模糊问题

canvas的drawImage() 函数有个特别容易混淆搞错的地方。它的5参数和9参数用法的参数位置是不同的。实际开发中没注意到这一点,会让自己特别困惑问题出在哪!汗!

drawImage()方法有一个非常怪异的地方,大家一定要注意,那就是5参数和9参数用法的参数位置是不一样的,这个和一般的API有所不同。一般API可选参数是放在后面。但是,这里的drawImage()使用9个参数时候,可选参数sx,sy,sWidth和sHeight是在前面的。如果不注意这一点,有些表现会让你无法理解。

且drawImage()函数插入的图形在移动端dpr >1屏幕同样会有图片模糊的问题。 在移动端通过drawImage()载入另一个已绘制的Canvas元素时,也要注意对另一个canvas元素做兼容处理,还需要注意两者坐标系的不同。

// 设置canvas_bg宽高
canvas_bg.width = (config.unit * (scale_len - 1) + config.width) * dpr;
canvas_bg.height = config.height * dpr;
ctx_bg.scale(dpr, dpr);

...

// 初始化开始位置
point_x = (config.def - config.start) / config.capacity * config.unit;
//在主画布ctx上,通过drawImage()插入另一个canvas_bg画布;
ctx.drawImage(canvas_bg, point_x * dpr, 0, config.width * dpr, config.height * dpr, 0, 0, config.width, config.height);

复制代码

上面的代码中, canvas_bg画布同样需要处理上面提到的canvas模糊问题;在主画布ctx上,通过drawImage()插入另一个canvas_bg画布图形时,需要注意此时两者坐标系比例的不同,此时canvas_bg的坐标系是根据dpr缩放后的。

当canvas绘制尺寸或drawImage插入图像、getImageDate获取图形资源等尺寸大于某个阈值时,可能会出现绘制空白问题。

在实际开发中遇到,canvas绘制尺寸或drawImage插入图像、getImageDate获取图形资源等尺寸大于某个阈值时,渲染出来的图片整个都是空白。这个具体的阈值不确定,跟运行环境有关。但这应该也是drawImage绘制的一个不知何时爆发的隐患。 比如下图,绘制的刻度尺画布尺寸过大,截取后渲染到主画布上,整个刻度空白,但不影响交互。

image.png

组件库封装

以上是开发思路及主要代码。接下来要封装成开源的组件库文件,可以直接引入到项目中使用。
首先对代码进行适当改造,封装成类,操作实例的方式构建代码。以提高代码复用及避免多次引入使用的相互影响。
最终的代码结构:

image.png
具体代码请移步到项目中查看。

为了简便,直接使用vue-cli3提供的的构建库模式构建组件库。构建的库会输出 CommonJS 包、UMD包等版本。使用时自取对应文件。

image.png

canvas-scale组件库已发布到npm上。npm地址:www.npmjs.com/package/can…

项目链接:

github源码地址:github.com/now1then/ca…;
文章-语雀:www.yuque.com/nowthen/lon…;

涉及的另一篇文章-「记录canvas使用及常见问题

组件demo体验地址:rnlvwyx.cn:3333/#/demo;(涉及的配置项演示待补充...)

有问题欢迎探讨...