Canvas 圆环滑动控件实现原理解析

前言

        为什么会有使用class+canvas绘制自定义圆环进度条的想法呢?首要条件是现有的组件不满足产品要的圆环的需求;其次是平时在code的时候,对于class和canvas的运用极少,但是class和canvas的使用有很多优点,于是决定使用一个新的方式进行构思,也是对class和canvas一种熟悉。

        本文主要对圆环滑动控件实现的类展开解析,DragAcr是一个使用class封装的圆环滑动控件,基于原生 Canvas 实现,支持拖动进度、渐变颜色、文字显示等功能,适用于移动端和 PC 场景。相比 DOM 或 SVG 实现方式,它具备更高的性能与更强的自定义能力。

核心功能概述

  • 📐 支持设定起始和结束角度,适配任意弧形范围
  • 🎨 支持颜色渐变,滑块样式、圆形线带完全可配置
  • 👆  支持鼠标与触控拖动、点击设置
  • 📈  支持实时回调、动态设置值
  • 📦 使用class封装,易于复用和扩展

架构设计与主要模块

构造函数和初始化参数

  constructor(param) {
    this.initParam(param)
    this.draw(this.value)
  }

构造函数接受一个配置对象,会调用两个方法: 

  • initParam():初始化圆环配置参数(颜色、角度、宽度、值等)
  • draw(this.value):进行初始绘制

参数初始化initParam(param)

initParam(param) {
    const {
      el,// 	要挂载 canvas 的 DOM 元素
      startDeg = 0,//弧形起始角度(以 π 为单位)
      endDeg = 1,//弧形结束角度(默认是半圆)1π = 180°
      innerColor = "#F67F3C",
      outColor = "#c0c0c0",//外圆弧的底色(背景颜色) 透明色
      innerLineWidth = 1,
      innerLineDash = false,
      outLineWidth = 20,
      counterclockwise = true,//控制是否逆时针绘图
      slider = 10,
      color = ["#06dabc", "#33aaff"],// 外圆弧的高亮颜色(进度颜色)
      sliderColor = "#fff", // 滑块边框颜色
      sliderBorderColor = "#0A84FF",// 滑块颜色
      value = 0,//当前的数值(0~100)
      change = (v) => { },//外部传入的回调函数
      mouseUp = (v) => { },//
      textShow = false,//否显示数值文字
      showDrag = true,//是否显示滑块
      unitVal = '℃',
    } = param;
    this.el = el;
    this.width = el.offsetWidth
    this.height = el.offsetHeight;
    this.center = this.width / 2
    this.radius = this.width / 2 - 30; //根据传入的宽高计算半径,比画布小30px,留出padding
    this.initCanvas(el);// 创建canvas元素并挂在上去
    this.startDeg = startDeg;
    this.endDeg = endDeg;
    this.residueDeg = 2 - startDeg; //用于弧度换算,防止逆时针方向出错。
    this.innerColor = innerColor;
    this.outColor = outColor;
    this.innerLineWidth = innerLineWidth;
    this.innerLineDash = innerLineDash;
    this.outLineWidth = outLineWidth;
    this.counterclockwise = counterclockwise;
    this.slider = slider;
    this.color = color;
    this.sliderColor = sliderColor;
    this.sliderBorderColor = sliderBorderColor;
    this.value = value;
    this.textShow = textShow;
    this.showDrag = showDrag;
    //保存回调函数
    this.change = change;
    this.mouseUp = mouseUp;
    this.isDown = false;//用于标记滑块是否被按住
    this.unitVal = unitVal;
    this.evpoint = {}//记录鼠标/手指的点击位置
    this.event(el)// 最后调用 event(el) 为这个圆形滑块绑定拖动/点击事件
  }

配置项:

  •  el: 容器 DOM 元素
  • startDeg/endDeg: 控制绘制圆弧的起始和终止角度(以 π 为单位)
  • value: 当前值(0-100)
  • change, mouseUp: 拖动过程与结束时的回调
  • textShow, showDrag: 控制是否显示文本或滑块
  • unitVal: 单位显示,比如
  • 结构参数并设置默认值
  • 根据el容器计算canvas尺寸
  • 调用initCanvas创建canvas
  • 调用event()绑定事件

Canvas 初始化initCanvas(dom)

const dpr = window.devicePixelRatio || 1;
this.canvas.width = this.width * dpr;
this.canvas.height = this.width * dpr;
...
this.ctx.scale(dpr, dpr)
  • 创建canvas元素并挂载在指定的DOM上
  • 使用 devicePixelRatio 提高高清屏幕下的绘制质量。

  • 设置 canvas 尺寸并缩放,提升抗锯齿能力。

  • 准备好绘制上下文

draw(value)

//绘图
  draw(value) {
    this.ctx.imageSmoothingEnabled = false;//关闭图像平滑处理,确保画布像素渲染精准
    this.ctx.clearRect(0, 0, this.width, this.width);//除画布上已有图形,准备重新绘制

    this.ctx.save();//保存当前绘图状态,避免后续设置影响到外部图形(例如颜色、线宽等)

    // 计算开始/结束角度(弧度) 固定滑块可变角度
    let startDeg = this.counterclockwise ? Math.PI * (2 - this.startDeg) : Math.PI * this.startDeg
    let endDeg = this.counterclockwise ? Math.PI * (2 - this.endDeg) : Math.PI * this.endDeg

    // 绘制外侧圆弧(外侧圆弧是固定的)
    this.ctx.beginPath();
    this.innerLineDash && this.ctx.setLineDash([1, 0]) //设置虚线样式,[实线长度, 虚线长度]
    this.ctx.arc(this.center, this.center, this.radius, startDeg, endDeg, this.counterclockwise); // 绘制外侧圆弧
    this.ctx.strokeStyle = this.outColor;//灰色背景 this.outColor
    this.ctx.lineCap = "round";
    this.ctx.lineWidth = this.outLineWidth;
    this.ctx.stroke();// 绘制外侧圆弧


    // 这也是可变颜色圆弧的结束角度,动态渲染的关键
    let Deg = this.valToDeg(value)// 将值(0-100)或者自定义转化为弧度,默认是0

    if (!this.showDrag) return;


    // 绘制可变圆弧
    let themeColor = (typeof this.color == 'String') ? this.color : this.setLinearGradient()// 如果传入的是字符串,则直接使用该颜色,否则使用渐变色
    this.ctx.beginPath();
    this.ctx.arc(this.center, this.center, this.radius, startDeg, Deg, this.counterclockwise); // 可变圆弧
    this.ctx.strokeStyle = themeColor;
    this.ctx.lineCap = "round";
    this.ctx.lineWidth = this.outLineWidth;
    this.ctx.stroke();

    // 绘制滑块
    this.P = this.DegToXY(Deg)// 滑块的坐标

    // 滑块外圈
    this.ctx.beginPath();
    this.ctx.moveTo(this.center, this.center,);
    this.ctx.arc(this.P.x, this.P.y, this.slider + 5, 0, Math.PI * 2, false); // 绘制滑块内侧
    this.ctx.fillStyle = this.sliderColor;
    this.ctx.fill();

    // 滑块边框
    this.ctx.beginPath();
    this.ctx.moveTo(this.center, this.center);
    this.ctx.arc(this.P.x, this.P.y, this.slider + 2, 0, Math.PI * 2, false); // 绘制滑块
    this.ctx.fillStyle = this.sliderBorderColor;
    this.ctx.fill();

    // 滑块中间的白色小圆
    this.ctx.beginPath();
    this.ctx.moveTo(this.center, this.center);
    this.ctx.arc(this.P.x, this.P.y, this.slider - 3, 0, Math.PI * 2, false); // 绘制滑块
    this.ctx.fillStyle = "#FFFFFF";
    this.ctx.fill();

    this.ctx.beginPath();
    this.ctx.closePath();

    // 绘制小三角形指向文本
    if (!this.textShow) return;
  }
  • 清空旧图像 clearRect()
  • 画背景圆弧:用 outColor 绘制完整背景弧。
  • 画进度圆弧:从 startDeg 到当前值角度,渐变颜色 this.color

  • 绘制滑块(外圆、边框、内圆)

  • 可选显示文字+三角指示等

角度、坐标转换

  • valToDeg(value):0~100弧度(用于画弧)

  • DegToXY(deg):弧度→坐标(滑块位置)

  • XYToDeg(x,y):坐标→弧度(鼠标点击或拖动)

  • spotchangeXY / respotchangeXY:坐标系转换函数(Canvas和中心坐标之间)

event(dom)

自动判断是否移动端,绑定相应事件

  • touchstart/mousedown → OnMouseDown
  • touchmove/mousemove → OnMouseMove

  • touchend/mouseup → OnMouseUp

  • click → onCanvasClick

交互逻辑

 鼠标按下判断是否点中滑块

   /**
   * 手指或者鼠标点击滑块时触发
   * @param {*} evt 
   * @returns 
   */
  OnMouseDown(evt) {
    let range = 10;// 设置容差范围为10,用于判断点击是否在滑块附近
    let X = this.getx(evt);// 获取鼠标/手指在画布canvas上的坐标,会自动兼容PC和移动端
    let Y = this.gety(evt);
    this.evpoint.x = X;// 把点击的坐标保存在evpoint对象上
    this.evpoint.y = Y;
    let P = this.P  // 滑块中心点坐标
    let minX = P.x - this.slider - range;
    let maxX = P.x + this.slider + range;
    let minY = P.y - this.slider - range;
    let maxY = P.y + this.slider + range;
    if (minX < X && X < maxX && minY < Y && Y < maxY) {   //判断鼠标是否在滑块上
      this.isDown = true;// 表示开始滑动了
    } else {
      this.isDown = false;
    }
    evt.preventDefault()//是阻止默认行为(如滚动页面)。
  }

拖动中更新值

  /**
   * 拖动滑块时触发
   * @param {*} evt 
   * @returns 
   */
  OnMouseMove(evt) {
    this.isDraging = true// 标记正在拖动
    if (!this.isDown) return; // 不在滑块上
    this.evpoint.x = this.getx(evt);// 获取当前坐标并转成以圆心为原点的坐标
    this.evpoint.y = this.gety(evt);
    let point = this.spotchangeXY(this.evpoint);
    let deg = this.XYToDeg(point.x, point.y); // 计算坐标点的角度
    deg = this.counterclockwise ? deg : Math.PI * 2 - deg; // 顺逆时针处理
    const radian = deg / Math.PI; //  将角度转化为弧度
    let val =
      ((radian - (radian > this.startDeg ? this.startDeg : -this.residueDeg)) /
        (this.endDeg - this.startDeg)) *
      100; // 将弧度转成当前滑动的“百分比值(0~100)

    if (val > 100 || val < 0) return; // 超过范围(0~100)直接退出。
    if (!this.isMobile && Math.abs(val - this.value) > 10) return;//如果是 PC 且拖动太快(变化大于 10),忽略,起到降速防误操作作用
    this.animate = requestAnimationFrame(this.draw.bind(this, val));//用 requestAnimationFrame() 重绘动画(比 setTimeout 更流畅
    if (this.value != Math.round(val)) {
      // 如果值真的改变了,就触发外部回调this.change(this.value, "OnMouseMove")
      this.value = Math.round(val);
      this.change(this.value, "OnMouseMove")
    }
  }

鼠标抬起

  /**
   * 松开滑块时触发
   * @param {*} evt 
   * @returns 
   */
  OnMouseUp(evt) {
    const _this = this
    this.mouseUp();//拖动结束时,触发外部的回调函数mouseUp
    cancelAnimationFrame(_this.animate);// 取消任何未完成的动画
    if (this.isDraging) {
      // 如果拖动了,说明是拖动完成->再次触发change()
      this.change(this.value, "onClick")
    } else {
      // 如果根本没拖动,只是点击了一下->进入点击事件
      this.onCanvasClick()
    }
    // 重置状态,表示这次交互完成
    evt.preventDefault()
    this.isDraging = false
    this.isDown = false
  }

 点击事件

  // 点击画布时触发
  // 将点击的坐标转换为画布上的坐标
  onCanvasClick() {
    // 将画布上的坐标转换为角度
    let point = this.spotchangeXY(this.evpoint);// 将点击点转换为以圆心为原点的坐标
    let deg = this.XYToDeg(point.x, point.y);// 然后将坐标转换成角度(弧度
    deg = this.counterclockwise ? deg : Math.PI * 2 - deg;//如果是顺时针,还要取反方向。
    const radian = deg / Math.PI;
    //将弧度 deg 转换为实际的百分比值(0~100)
    let val =
      ((radian - (radian > this.startDeg ? this.startDeg : -this.residueDeg)) /
        (this.endDeg - this.startDeg)) *
      100;
    console.log('onCanvasClick----',val)

    if (val > 100 || val < 0) return;
    this.animate = requestAnimationFrame(this.draw.bind(this, val));
    //只要值发生变化,就触发 change() 回调。
    if (this.value != Math.round(val) || this.isDraging) {
      this.value = Math.round(val);
      this.change(this.value, "onClick")
    }
  }
操作方法关键变量
按下OnMouseDown()判断是否点到滑块,设置 isDown
移动OnMouseMove()实时计算角度→ 值,更新位置与回调
抬起OnMouseUp()区分“拖动“与”点击”,执行结束回调
点击onCanvasClick()点击画布任意处,若合法,则自动跳到相应为止,并设置对应值

外部接口

这个类的设计提供了一个外部接口,使用这个类的时候允许开发者通过代码设置控件值,使得外部可以通过 drag.setValue(60) 设置进度条到 60%。

  //改变值
  /**
   * 提供给组件外部的【接口】方法,允许外部调用这个方法来改变值
   * @param {*} newValue--传入的值(0-100)
   * @param {*} newValue 
   * @returns 外部可以通过setValue(60)来设置进度条值为60%
   */
  setValue(newValue) {
    if (this.isDown) return;// 如果用户正在拖动中,则不允许修改,避免冲突
    let range = this.endDeg - this.startDeg;
    let val = range / 100 * newValue;
    if (this.counterclockwise && (val != 0)) val = 2 - val;
    let startDeg = this.counterclockwise ? (2 - this.startDeg) : this.startDeg;
    let newDeg = (startDeg + val) * Math.PI;

    if (newValue >= 100) newValue = 100;
    if (newValue <= 0) newValue = 0;

    // 更新value值,重绘
    this.value = newValue;
    this.P = this.DegToXY(newDeg);
    this.draw(this.value);
    // this.change(this.value);
  }

工具方法

坐标转弧度

  • 用户点击的位置坐标,转换为弧度角度,用于判断点击了圆弧的哪一段。
  // 将坐标点转化为弧度
  /**
   * 根据用户点击的坐标,转换为弧度角度,用于判断点击了圆弧哪一段
   * @param {*} lx x坐标
   * @param {*} ly y坐标
   * @returns 
   */
  XYToDeg(lx, ly) {
    let adeg = Math.atan(ly / lx)//
    let deg;
    // 以下是根据x,y的正负判断落在哪个象限,加上对应的π或2π补正
    if (lx >= 0 && ly >= 0) {
      deg = adeg;
    }
    if (lx <= 0 && ly >= 0) {
      deg = adeg + Math.PI;
    }
    if (lx <= 0 && ly <= 0) {
      deg = adeg + Math.PI;
    }
    if (lx > 0 && ly < 0) {
      deg = adeg + Math.PI * 2;
    }
    return deg
  }

 获取触发点在canvas上的相对坐标

  • 兼容 PC 和移动设备。

  • 获取点击/拖动事件在 canvas 中的相对坐标

  //获取鼠标在canvas内坐标x
  /**
   * 获取点击/拖动事件在canvas中相对坐标
   * @param {*} ev 
   * @returns 
   */
  getx(ev) {
    if (!this.isMobile) return ev.clientX - this.el.getBoundingClientRect().left;
    return ev.touches[0].pageX - this.el.getBoundingClientRect().left;
  }

  //获取鼠标在canvas内坐标y
  gety(ev) {
    if (!this.isMobile) return ev.clientY - this.el.getBoundingClientRect().top;
    return ev.touches[0].pageY - this.el.getBoundingClientRect().top;
  }

节流函数

  • 防止函数(比如拖动时的 mousemove)触发频率过高,造成卡顿。

  • 只有距离上次执行超过 10ms,才会执行一次函数

  //节流
  throttle(func) {
    let previous = 0;
    return function () {
      let now = Date.now();
      let context = this;
      let args = arguments;
      if (now - previous > 10) {
        func.apply(context, args);
        previous = now;
      }
    }
  }

以上便是使用class+canvas绘制单元环进度条控制相关的解析了。接下来我举例一下如何使用。

使用

抽离定义组件 ModeTempArc

使用组件 

引入

import TempArc from "./components/modeTempArc.vue";

使用

<template>
    <div class="page">
        <TempArc 
            ref="TempArcCool"
            :value="String(tempValObj.cool_set_temperature)"  
            :configMsg="getConfigMsg"
            @change="(val) => handlePercent(val)" 
/>
     </div>
</template>


<script>
...

data() {
  return {
    maxVal: 60, // 最大湿度
    minVal: 0, // 最小湿度
    stepSizeVal: 1, //步长
    unitVal: '℃', // 湿度单位 1: 摄氏度 2: 华摄氏度
    tempValObj: {
      cool_set_temperature: 3,
    }
  }
}


computed: {
  getConfigMsg() {
    return {
      maxVal: this.maxVal,
      minVal: this.minVal,
      stepSizeVal: this.stepSizeVal,
      unitVal: this.unitVal
    }
  }
}

....

methods:{
  handlePercent(val){
    // 更新...
    // 异步请求等等....
  }
}

</script>

以上便是单元环进度条组件的的相关解析与应用啦,主要是讲解主要的思路,有些代码是没贴全的,如果有疑问的朋友也可以评论区告诉我~若有不足的地方也欢迎指正~

等下次有时间我就把双圆环进度条的相关构建和使用也出个文章总结记录一下~下次见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值