刻度尺控件-数值选择遥控器


theme: github highlight: atom-one-dark

背景

在项目迭代中需要实现滑动刻度尺实时反馈数值进行后续操作,具体要实现刻度尺控件的效果如下:

最终实现的效果: image.png

功能

基本功能

  • 刻度尺左右滑动选值,中心刻度值高亮;
  • 刻度尺滑动到左右两侧边缘值时回弹;
  • 监听滑动事件,实时响应中心刻度值;

高级功能

  • 刻度尺封装为组件,支持刻度尺配置参数传入渲染;
  • 刻度尺组件对外暴露一些方法:清空、更新和重载刻度尺等方法;
  • 解决移动端绘制模糊的问题;
  • 添加缓动效果,支持平滑滚动;
  • 兼容移动端/pc端滑动;
  • 支持配置磁吸效果(磁吸效果指刻度尺滑动到两个刻度间时能够自动移动到离它最近的刻度线);

组件的使用

在项目中引入组件后,创建刻度尺的实例:

const configs = {
  interval: 10,
  start: 0,
  end: 2,
  capacity: 0.01, // 刻度容量值
  suffix: "x", // 单位
  openUnitChange: true, // 磁吸效果
  background: "#000", // 设置颜色则背景为对应颜色虚幻效果,不设置默认为透明色。
  midLineColor: "#3D81F7", // 中心线颜色
  scaleLineColor: "#fff", // 刻度线颜色
  bottomLineColor: "transparent", // 底线,标准线颜色
};

const myScale = new Scale(element, configs, callBack);
function callBack(value) {
console.log(value);
}

  • element:一个 HTML 节点,作为 canvas 的容器
  • configs:刻度尺的一些配置项
  • callback:刻度变更时的回调函数,可通过该回调函数获取最新刻度值

⚠️注意:因为 canvas 的宽高设置取的是容器的宽高,所以 element 需要设置宽高

配置参数「configs」说明

// 默认配置
defaultConfig = {
  start: 0,
  end: 100,
  interval: 10,
  capacity: 1,
  suffix: "",
  background: "#fff",
  midLineColor: "#087af7",
  scaleLineColor: "#999",
  bottomLineColor: "transparent",
  openUnitChange: true,
  fontColor: "rgba(255,255,255,0.4)",
  highlightFontColor: "#fff", 
  font: "12px PingFangSC, PingFang SC;"
};

configs 中的每一项都不是必填项,有默认的配置:

  • start: number; // 刻度起始数值(最小的刻度值)
  • end: number; // 刻度结束数值(最大的刻度值)
  • interval: number; // 两刻度间的距离 单位'px'
  • capacity: number; // 刻度容量值, 一刻度的数值
  • suffix: string; // 数值的单位
  • background: string; // 设置颜色则背景为对应颜色虚幻效果,不设置默认为透明色。
  • midLineColor: string; // 中心线颜色
  • scaleLineColor: string; // 刻度线颜色
  • bottomLineColor: string; // 底线颜色
  • openUnitChange: boolean; // 是否开启刻度的磁吸效果
  • fontColor: string; // 刻度数值文字颜色
  • highlightFontColor: string; // 中心刻度文字高亮的颜色
  • font: string; // 刻度数值的字体样式

方法

方法名 说明 参数
update 更新画布当前刻度值,重绘刻度尺 value:number  中心刻度值
reload 传入配置是更新刻度尺重新载入,若不传新的配置则是刷新当前刻度尺 config?: ScaleConfig 刻度尺配置
clear 清除当前画布,清除刻度尺上所有监听事件

技术方案详解

一些重要数据的计算公式:

$$ \text{\small1px对应的刻度数值} = \cfrac{\small一刻度容量}{\small刻度间像素间距} = \cfrac{\text{capacity}}{\text{interval}} $$

$$ \text{\small1刻度数值对应的像素距离px} = \cfrac{\small刻度间像素间距}{\small一刻度容量} = \cfrac{\text{interval}}{\text{capacity}} $$

$$ \text{\small该刻度数值对应的刻度间隔数} = \cfrac{\small 刻度数值}{\small 每刻度数值容量} = \cfrac{\text{num}}{\text{capacity}} $$

一、canvas 绘制刻度尺

1.1 初始化画布 _init()

  1. 获取中间刻度线对应的数值 current_num

$$ \begin{align} \text{current_num} & = \text{Math.floor( } \cfrac{\text{start} + \cfrac{\text{end - start}}{2}}{capacity}) \times capacity \ \end{align} $$

  1. 设置容器 container 的宽高,然后根据 dpr 设置画布 canvas 的宽高,确保 canvas 宽高为整数;
  2. 画布坐标 ctx 根据 dpr 缩放 ,解决移动端模糊问题;
  3. 如果传入的参数有误,提示错误
  4. 依次绘制刻度尺 _drawScale()、中心刻度线 _drawMidLIne()、背景颜色 _drawBackground()
  5. 增加事件监听 _addEvent()

1.2 绘制刻度尺_drawScale()

绘制刻度尺的两种思路:

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

第一种方案主要就是通过 context.getImageData(sx, sy, sWidth, sHeight);截取刻度尺图像区域,通过context.putImageData(imageData, 0, 0);上面截取的ImageData对象的数据绘制到主画布上。

第一种方案:

优点:在实现上更简单;刻度尺画布只需绘制一次,滑动时无需重绘刻度尺画布;更直观体现刻度移动

缺点:但在绘制刻度区间跨度大时,性能不好,且canvas画布尺寸过大,会出现绘制空白的问题。

第二种方案:

比较难定位可视区域刻度尺的初始值、结束值,且滑动时整个画布都重新计算绘制每个刻度。乍一看实现更麻烦,滑动时整个画布都得实时绘制,但相比于第一种方案的致命缺陷,效果、性能及兼容性更佳。

最终选取第二种方案,其思路和流程如下:

  1. 创建新的刻度画布,作为底层图片,设置 canvas_bg 宽高时要乘上 dpr 的原因见开发中遇到的问题
const { config, ctx } = this;
// 创建新的刻度画布 作为底层图片
const canvas_bg = document.createElement("canvas");
const ctx_bg = _canvas_bg.getContext("2d");

// 设置canvas_bg宽高
canvas_bg.width = config.width * dpr;
canvas_bg.height = config.height * dpr;
ctx_bg.scale(dpr, dpr);

  1. 以中点刻度为基准,获取画布最左侧刻度值 begin_num,保证滑动条的中间位置始终在视图的中心

$$ \begin{align} \text{begin_num} & = 中心刻度线数值 - 画布宽度的一半 \times 1px对应的刻度数值 \ & = \text{current_num}- \cfrac{\text{width}}{2} \times \cfrac{\text{capacity}}{\text{interval}} \end{align} $$

// 以中点刻度为基准,获取画布最左侧刻度值, 保证滑动条的中间位置始终在视图的中心
const begin_num = this.current_num - (config.width / 2) * (config.capacity / config.interval); 
  1. 计算刻度尺一共要绘制的刻度条数 scale_len

$$ \begin{align} \text{scale_len} & = \text{Math.floor( } \cfrac{画布宽度的一半}{刻度间像素间隔} ) + 1\ & = \text{Math.floor( }\cfrac{\text{width}}{\text{interval}}) + 1 \end{align} $$

// 刻度尺一共要绘制的刻度条数
const scale_len = Math.floor(config.width / config.interval) + 1; 
  1. 计算画布内实际绘制的刻度条数 real_len

$$ \begin{align} \text{real_len} & = \cfrac{刻度尺结束刻度值-刻度尺起始刻度值}{每刻度数值容量} + 1\ & = \cfrac{\text{end - start}}{\text{capacity}} + 1 \end{align} $$

// 画布内实际绘制的刻度条数
const real_len = Math.ceil((config.end - config.start) / config.capacity) + 1; 
  1. 计算space_num,其表示实际要绘制的第一条整刻度距离 begin_num 差了多少数值 ,并将其转换为实际的距离(px) space_x

开始介绍了刻度数值对应的刻度间隔数怎么计算,所以 begin_num / capacity 起始计算的就是最左侧的刻度值对应有多少个间隔,通过 Math.ceil 向上取整再乘以 capacity 后就可以得到距离 begin_num 最近的实际绘制的第一条刻度线的刻度值是多少

$$ \begin{align} \text{space_num} & = \text{Math.ceil( } \cfrac{最左侧刻度值}{每刻度数值容量} \text{ )} \times 每刻度数值容量 - 最左侧刻度值 \ & = \text{Math.ceil( } \cfrac{\text{begin_num}}{\text{capacity}} \text{ )} \times \text{capacity} - \text{begin_num} \end{align} $$

$$ \begin{align} \text{space_x} = \text{space_num} \times 1刻度数值对应的像素距离px \ & = \text{space_num} \times \cfrac{\text{interval}}{\text{capacity}} \end{align} $$

const space_num = Math.ceil(begin_num / config.capacity) * config.capacity - begin_num;
const space_x = new Decimal(space_num)
  .times(config.interval / config.capacity)
  .toNumber(); // 页面中实际绘制的第一条刻度线距离画布左侧的距离(px)
  1. 遍历绘制刻度线,在滑动时,实时计算当前中间刻度值,并依据上面的绘制步骤,重绘整个画布。

由上图分析可知,两个长刻度可看作一个循环,一个循环两端的刻度一定是 10 个间隔(刻度数值容量)的整数倍,循环的中心一定是 5 个间隔的整数倍,所以得到了绘制刻度线的算法:

if (cur_num % (capacity * 10) === 0) {
// 绘制一个循环的两端(红色线)
} else if (cur_num % (capacity * 5) === 0) {
// 绘制一个循环的中心(蓝色线)
} else {
// 绘制其他的小刻度线
}
  1. 在画布上绘制该刻度尺背景图
ctx.drawImage(
  canvas_bg,
  0,
  0,
  config.width * dpr,
  config.height * dpr,
  0,
  0,
  config.width,
  config.height
);

1.3 绘制中心刻度线_drawMidLIne()

中心刻度线一直停留在画布中间,表示当前的刻度值

绘制一条线 + 一个倒三角

_drawMidLine() {
  const { config, ctx } = this;

const mid_x = Math.floor(config.width / 2);
ctx.beginPath();
ctx.fillStyle = config.midLineColor || “#087af7”;
ctx.fillRect(mid_x - 1, 15, 2, 19);
ctx.stroke();
// 绘制三角形
ctx.moveTo(mid_x, 10);
ctx.lineTo(mid_x - 6, 0);
ctx.lineTo(mid_x + 6, 0);
ctx.fill();
ctx.closePath();
}

1.4 设置背景颜色_drawBackground()

根据 config.background 来进行设置

  • 若传入空,则整个画布为透明色,两端无虚幻效果
  • 若传入颜色值,背景设置为该颜色值且额外为画布的两端营造刻度线虚幻的效果
_drawBackground() {
  const { ctx, config } = this;

if (config.background) {
ctx.beginPath();
const gradient1 = ctx.createLinearGradient(0, 0, config.width, 0);
gradient1.addColorStop(0, “rgba(0, 0, 0, 0.95)”);
gradient1.addColorStop(0.1, “rgba(0, 0, 0, 0)”);
gradient1.addColorStop(0.9, “rgba(0, 0, 0, 0)”);
gradient1.addColorStop(1, “rgba(0, 0, 0, 0.95)”);
ctx.fillStyle = gradient1;
ctx.fillRect(0, 20, config.width, 12);
ctx.closePath();
}
}

二、刻度尺交互

交互主要就是滑动刻度尺时监听滑动事件,相应进行的一系列响应,需实现:

  • 能够左右滑动刻度尺,中心线始终在画布中央,当前刻度值高亮,兼容 PC 端;
  • 使用缓动函数,增添平滑移动效果:在用户滑动得很快的情况下也能有滑动的过程,体验感较好;
  • 边界处理:滑到刻度尺最左侧和最右侧时刻度尺能够回弹;
  • 是否开启磁吸效果:若开启,在滑动到不是整刻度时自动移动到最近的整刻度。

整体思路:监听左右滑动事件,获取每次手指滑动的距离,计算刻度需要移动的距离得到滑动后当前的刻度值后,重新绘制canvas画布。重新绘制画布的方法如下:

_updateCanvas() {
  const { ctx, config } = this;
  // 先清空画布再绘制,否则有绘制重叠
  ctx.clearRect(0, 0, config.width, config.height);

this._drawScale();
this._drawMidLine();
this._drawBackground();
}

但实际重新绘制 canvas 时,都利用window.requestAnimationFrame()包裹了_updateCanvas,目的是优化渲染频率。

window.requestAnimationFrame(() => {
  this._updateCanvas();
});

具体步骤:

  1. 注册需监听的事件,兼容移动端和PC端:移动端监听 touch 事件,PC端监听 mouse 事件,移动端获取当前触摸点X坐标:e.touches[0].pageX,PC端获取鼠标X坐标:e.pageX
// 注册事件,移动端和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);
  1. startEvent中,获取移动瞬间触碰点的位置(x 坐标)单位为 px,记录开始移动那刻的时间;
const start = (e) => {
  e.stopPropagation();
  e.preventDefault();
  ifMove = true;
  if (!e.touches) {
    // 兼容PC端
    start_x = e.pageX;
  } else {
    start_x = e.touches[0].pageX;
  }
  lastMove_x = start_x;
  lastMove_time = e.timeStamp || Date.now();
};
  1. 在 moveEvent 中,计算移动的距离,实时更新刻度值,实时绘制刻度尺,并调用传入的回调函数传回刻度值,500ms 更新一次上一次移动的位置和时间;

const move = (e) => {
  e.stopPropagation();
  e.preventDefault();
  const current_x = e.touches ? e.touches[0].pageX : e.pageX;
  if (ifMove) {
    const move_x = current_x - start_x;
    this.current_num =
      this.current_num - move_x * (config.capacity / config.interval);
    window.requestAnimationFrame(() => {
      this._updateCanvas();
    });
start_x = current_x;
const current_time = e.timeStamp || Date.now();
if (current_time - lastMove_time > 500) {
  lastMove_time = current_time;
  lastMove_x = start_x;
}

}
};

  1. endEvnet事件中,计算手机滑动的速度,若速度太快(>0.3)则使用缓动函数进行平滑移动处理,在更新绘制画布之前,对边界情况和磁吸效果进行处理。
const end = (e) => {
  const end_x = e.changedTouches ? e.changedTouches[0].pageX : e.pageX;
  const end_time = e.timeStamp || Date.now();
  const v = -(end_x - lastMove_x) / (end_time - lastMove_time); // 手指划动速度,右划为正

// 边界情况和磁吸效果处理
const _boudaryHanlder = () => {
// 边界值处理
if (this.current_num < config.start) {
this.current_num = config.start;
} else if (this.current_num > config.end) {
this.current_num = config.end;
}
// 是否开启磁吸效果
if (config.openUnitChange) {
this.current_num =
Math.round(this.current_num / config.capacity) * config.capacity; // 四舍五入
}
};

// 平滑移动
const step = () => {
this.current_num = slowActionfn(
t,
initial_num,
(config.capacity / config.interval) * v * 100, // 100为放大因子
d
);

_boudaryHanlder();
this._updateCanvas();

t++;
if (t &lt;= d) {
  // 继续运动
  window.requestAnimationFrame(step);
} else {
  this.callback(this.current_num); // 移动结束回传当前最新的刻度值
  // 结束
  return;
}

};

ifMove = false;

let t = 0;
const d = 15; // 步数为15
let initial_num = 0; // 初始值
if (Math.abs(v) >= 0.3) {
initial_num = this.current_num;
step();
} else {
// 移动的速度太慢了,不用缓动
_boudaryHanlder();
this._updateCanvas();
this.callback(this.current_num); // 移动结束回传当前最新的刻度值
}
};

开发中遇到的问题

1. 移动端绘制 canvas 模糊问题

未处理时的效果:

处理后的效果:

上图中可见,在未做处理时,绘制的 canvas 图形和文字都模糊失真。

问题原因

详细原因可以见文章,写的比较详细:移动端适配相关知识

在移动端高清屏幕上,经常会遇到 Canvas 图形模糊的问题。本质上跟移动端图片模糊问题是一样的。canvas绘制成的图像跟也是位图,在dpr > 1的屏幕上,位图的一个像素可能由多个物理像素来渲染,然而这些物理像素点并不能被准确的分配上对应位图像素的颜色,只能取近似值,所以在dpr > 1的屏幕上就会模糊。

在 PC 端绘制 canvas 图形,浏览器都直接把 1个 canvas 像素直接等于 1px 的 css 像素处理,这没有问题。而在dpr > 1的移动端屏幕上就不能直接这样处理。

解决方案

如「移动端适配相关知识」文章中所讲的为了保证图片质量,我们应该尽可能让一个屏幕像素来渲染一个图片像素,所以针对不同 dpr 的屏幕,我们需要展示不同分辨率的图片,类似的迁移到 canvas 中就是,针对不同 dpr 的屏幕,我们需要绘制不同分辨率的 canvas。需要注意的是不仅需要把 canvas 扩大,canvas 的坐标系也需要扩大

如:在 dpr=2的屏幕上绘制两倍的 canvas(@2x),在 dpr=3的屏幕上绘制三倍的 canvas(@3x)。

具体步骤如下:

  1. 通过window.devicePixelRatio获取当前设备屏幕的dpr;
  2. 获取或设置 canvas 容器的宽高;
  3. 根据dpr,设置canvas元素的宽高属性;在dpr = 2时相当于扩大画布2倍;
  4. 通过context.scale(dpr, dpr)缩放 canvas 画布的坐标系。在dpr = 2时相当于把canvas坐标系也扩大了两倍,这样绘制比例放大了2倍,之后 canvas 的实际绘制像素就可以按原先的像素值处理。
  5. 因为设置了canvas.style.width = `${config.width}px`;canvas.style.height = `${config.height}px`; 所以在渲染到屏幕时,扩大的画布图形又等比例缩放渲染到 canvas容器中,使1个canvas像素和1个物理像素相等,从而保证canvas图形的质量。
// 获取dpr
const dpr = window.devicePixelRatio; 
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 获取canvas容器的宽高
const { width: cssWidth, height: cssHeight } = container.getBoundingClientRect();

// 设置容器宽高
config.width = Math.floor(cssWidth);
config.height = Math.floor(cssHeight);

// 设置canvas在网页中占据的大小
canvas.style.width = ${config.width}px;
canvas.style.height = ${config.height}px;
// 根据dpr,设置canvas宽高,使1个canvas像素和1个物理像素相等
canvas.width = dpr * config.width;
canvas.height = dpr * config.height;

// 根据 dpr 缩放画布坐标系,解决移动端模糊问题
ctx.scale(dpr, dpr);

这里解释一下 canvas.style.width = `${config.width}px`; 和 canvas.width = dpr * config.width; 的区别:

  • canvas.style.width 和 canvas.style.height 是 CSS 属性,它们定义了 canvas 元素在网页上的显示大小,单位通常是像素(px),也可以是其他 CSS 单位(如em,%等)。这些值可以被视为canvas元素的“逻辑”或“布局”尺寸。
  • canvas.width 和 canvas.height 是 canvas 元素的 HTML 属性,它们定义了 canvas 的绘图表面的实际像素数。这些值可以被视为 canvas 的“物理”尺寸。

当改变 canvas.style.width 和 canvas.style.height 时,只是改变了 canvas 元素在网页上显示的大小,不会影响其实际的像素数。但是当改变 canvas.width 和 canvas.height时,实际上是在改变 canvas 的像素数,这会影响到在 canvas 上绘制的图像的清晰度。

看到这里,其实可以更清晰的解释如何解决 canvas 绘制模糊的问题的:

假设 dpr = 2,我们设置 canvas.style.width = 200px``canvas.style.height = "200px",那么canvas.width = 400``canvas.height = 400,这样设置之后实际上创建了一个 400x400 像素的 canvas,在渲染到浏览器上时它会被缩放到 200x200 像素(设备独立像素)的大小来显示在网页上,对应 400x400 的物理像素。这样就可以做到 1 个 canvas 像素 = 1 个物理像素,从而提高 canvas 在 dpr > 1 设备上的显示清晰度。

图解如下:

2. drawImage() 绘制背景图片模糊

drawImage() 函数绘制的图形在移动端dpr >1屏幕同样会有图片模糊的问题。

context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

image.png

根据上面的示意图,一开始绘制的代码如下:

ctx.drawImage(
  canvas_bg,
  0,
  0,
  config.width,
  config.height,
  0,
  0,
  config.width,
  config.height
);

// 或
ctx.drawImage(
canvas_bg,
0,
0,
config.width * dpr,
config.height * dpr,
0,
0,
config.width * dpr,
config.height * dpr,
);

但发现这样实现的刻度尺背景被放大了

应该将代码改为如下的样子:

ctx.drawImage(
  canvas_bg,
  0,
  0,
  config.width * dpr,
  config.height * dpr,
  0,
  0,
  config.width,
  config.height
);

解释一下:源图像(canvas_bg)的大小是 config.width * dpr 和 config.height * dpr,选取源图像的全部区域。然后将选取区域绘制到目标 canvas(ctx)上的(0,0)位置,大小是 config.width 和 config.height的区域。

这样做的结果是,源图像被缩放到了目标canvas的大小。目标 canvas 的大小(canvas.style.width 和 canvas.style.height设置)是 config.width 和 config.height,而源图像的大小是 config.width * dpr 和 config.height * dpr。所以需要将源图像缩放到目标 canvas 的大小,也就是目标 canvas 在网页上显示的大小,才能保证 canvas_bg 图像在目标canvas上完整显示,否则就会出现上面图像没有显示完整的情况。

3. 切换渲染另外一个尺子后滚动获取当前值有问题

原因:切换后上一个刻度尺的事件监听没有移除

解决方案: 在重新渲染另一个尺子前移除当前尺子的事件监听

// 重新载入,若不传新的配置则是重置当前刻度尺
reload(config?: ScaleConfig) {
  this._removeEvent();
  this.config = Object.assign({}, this.config, config);
  this._init();
}

这是一个从 https://juejin.cn/post/7368864814066384946 下的原始话题分离的讨论话题