uniapp canvas 自定义仪表盘 可滑动 可点击 中间区域支持自定义

javascript 复制代码
<template>
  <view class="container">
    <canvas
      canvas-id="fanCanvas" 
      id="fanCanvas" 
      class="fan-canvas"
      :style="{ width: canvasWidth + 'rpx', height: canvasHeight + 'rpx' }"
    ></canvas>
    
    <view class="progress-text">{{ isNaN(progress) ? 0 : Math.round(progress * 100) }}%</view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      /**
       * @description 进度条的当前进度值,范围 0~1,0 表示 0%,1 表示 100%
       */
      progress: 0.65,
      
      /**
       * @description 画布元素的实际渲染宽度,单位:px,由 initCanvas() 方法根据配置的 canvasWidth 自动计算
       */
      canvasW: 0,
      
      /**
       * @description 画布元素的实际渲染高度,单位:px,由 initCanvas() 方法根据配置的 canvasHeight 自动计算
       */
      canvasH: 0,
      
      /**
       * @description 扇形进度条圆心的 X 坐标(相对于画布左上角),单位:px
       */
      centerX: 0,
      
      /**
       * @description 扇形进度条圆心的 Y 坐标(相对于画布左上角),单位:px
       */
      centerY: 0,
      
      /**
       * @description 扇形进度条的半径,单位:px,由 initCanvas() 方法根据画布大小自动计算
       */
      radius: 0,
      
      /**
       * @description 每段弧线的实际线宽,单位:px,由 initCanvas() 方法根据 segmentThicknessRatio 自动计算
       */
      lineWidth: 0,
      
      /**
       * @description 扇形进度条的起始角度(弧度制),从该角度开始绘制进度条
       */
      startAngle: Math.PI * 0.78,
      
      /**
       * @description 扇形进度条的总角度(弧度制),从 startAngle 开始,绘制 totalAngle 的角度
       */
      totalAngle: Math.PI * 1.45,
      
      // ========== 可配置参数 ==========
      
      /**
       * @description 进度条画布的宽度(单位:rpx)
       * @reference 参考值:400, 600, 800, 1000
       */
      canvasWidth: 680,
      
      /**
       * @description 进度条画布的高度(单位:rpx)
       * @reference 参考值:400, 600, 800, 1000
       */
      canvasHeight: 680,
      
      /**
       * @description 进度条已填充部分的颜色,表示当前进度值对应的部分
       * @reference 参考值:'#bfff00' (亮绿色), '#00ff00' (纯绿色), '#ff6b6b' (红色), '#4ecdc4' (青色)
       */
      activeColor: '#bfff00',
      
      /**
       * @description 进度条未填充部分的颜色,表示还未达到的进度部分
       * @reference 参考值:'#3a3a3a' (深灰色), '#666666' (中灰色), '#999999' (浅灰色), '#2c2c2c' (黑色)
       */
      inactiveColor: '#3a3a3a',
      
      /**
       * @description 整个扇形进度条被分成多少段,数值越大分段越细密
       * @reference 参考值:12, 18, 24, 30, 36
       */
      segmentCount: 16,
      
      /**
       * @description 每段之间的间隙比例,控制分段之间的空白大小(范围:0~1)
       * @reference 参考值:0.1 (间隙很小), 0.15 (间隙适中), 0.3 (间隙较大), 0.45 (间隙很大)
       */
      segmentGapRatio: 0.15,
      
      /**
       * @description 每段弧线的线宽,相对于画布最小边(宽和高中的较小值)的比例(范围:0~1)
       * @reference 参考值:0.03 (很细), 0.05 (较细), 0.09 (适中), 0.12 (较粗), 0.15 (很粗)
       */
      segmentThicknessRatio: 0.03,
      
      /**
       * @description 是否允许通过点击进度条来改变进度值
       * @reference 参考值:true (允许点击), false (禁止点击)
       */
      enableClickHighlight: false,
      
      /**
       * @description 是否允许通过拖动/滑动进度条来改变进度值
       * @reference 参考值:true (允许拖动), false (禁止拖动)
       */
      enableDragHighlight: true,
      
      /**
       * @description 触摸区域的内半径相对于弧线半径的偏移比例,用于设置触摸区域的内边界(范围:0~1)
       * @reference 参考值:0.2 (触摸区域较宽), 0.3 (触摸区域适中), 0.5 (触摸区域较窄), 0.7 (触摸区域很窄)
       */
      touchAreaInnerRatio: 0.5,
      
      /**
       * @description 触摸区域的外半径相对于弧线半径的偏移比例,用于设置触摸区域的外边界(范围:0~1)
       * @reference 参考值:0.2 (触摸区域较窄), 0.3 (触摸区域适中), 0.5 (触摸区域较宽), 0.7 (触摸区域很宽)
       */
      touchAreaOuterRatio: 0.5,
      
      // ========== 可配置参数结束 ==========
      
      /**
       * @description 标识用户是否正在拖动进度条,用于控制拖动过程中的交互行为
       */
      isDragging: false,
      
      /**
       * @description 用于节流绘制操作,提升拖动时的流畅度
       */
      _drawTimer: null
    }
  },
  mounted() {
    this.initCanvas();
    // H5 环境下,添加原生事件监听作为备用
    this.$nextTick(() => {
      const canvasEl = this.$el && this.$el.querySelector ? this.$el.querySelector('#fanCanvas') : null;
      if (canvasEl && typeof document !== 'undefined') {
        // 使用原生事件监听(H5 更可靠)
        canvasEl.addEventListener('touchstart', this.handleStart, { passive: false });
        canvasEl.addEventListener('touchmove', this.handleMove, { passive: false });
        canvasEl.addEventListener('touchend', this.handleEnd, { passive: false });
        canvasEl.addEventListener('touchcancel', this.handleEnd, { passive: false });
        canvasEl.addEventListener('mousedown', this.handleStart);
        canvasEl.addEventListener('mousemove', this.handleMove);
        canvasEl.addEventListener('mouseup', this.handleEnd);
        canvasEl.addEventListener('mouseleave', this.handleEnd);
      }
    });
  },
  beforeDestroy() {
    // 清理绘制定时器
    if (this._drawTimer !== null) {
      cancelAnimationFrame(this._drawTimer);
      this._drawTimer = null;
    }
    // 清理事件监听
    const canvasEl = this.$el && this.$el.querySelector ? this.$el.querySelector('#fanCanvas') : null;
    if (canvasEl && typeof document !== 'undefined') {
      canvasEl.removeEventListener('touchstart', this.handleStart);
      canvasEl.removeEventListener('touchmove', this.handleMove);
      canvasEl.removeEventListener('touchend', this.handleEnd);
      canvasEl.removeEventListener('touchcancel', this.handleEnd);
      canvasEl.removeEventListener('mousedown', this.handleStart);
      canvasEl.removeEventListener('mousemove', this.handleMove);
      canvasEl.removeEventListener('mouseup', this.handleEnd);
      canvasEl.removeEventListener('mouseleave', this.handleEnd);
    }
  },
  methods: {
    initCanvas() {
      // H5 / App / 小程序:统一用 boundingClientRect 拿真实展示尺寸(最稳)
      const query = uni.createSelectorQuery().in(this);
      query
        .select('#fanCanvas')
        .boundingClientRect(rect => {
          if (!rect) return;

          this.canvasW = rect.width;
          this.canvasH = rect.height;

          // 根据画布大小自适应几何参数
          const minSide = Math.min(this.canvasW, this.canvasH);
          this.lineWidth = minSide * this.segmentThicknessRatio;

          const padding = this.lineWidth * 0.7;
          const maxRByH = this.canvasH / 2 - padding;
          const maxRByW = this.canvasW / 2 - padding;
          this.radius = Math.max(10, Math.min(maxRByH, maxRByW));

          this.centerX = this.canvasW / 2;
          this.centerY = this.canvasH / 2;

          this.drawFan();
        })
        .exec();
    },
    
    drawFan() {
      // 安全检查:确保几何参数已初始化
      if (!this.totalAngle || this.totalAngle <= 0 || !this.radius || this.radius <= 0 || 
          !this.centerX || !this.centerY || isNaN(this.progress)) {
        return;
      }

      const ctx = uni.createCanvasContext('fanCanvas', this);
      const w = this.canvasW || 300;
      const h = this.canvasH || 300;

      // 清空画布
      ctx.clearRect(0, 0, w, h);
      ctx.save();

      // 分段弧线(使用配置的颜色和分割距离)
      ctx.setLineWidth(this.lineWidth);
      // 为了让每段中间"断开",用平头线帽 + 配置的角度间隙
      ctx.setLineCap('butt');
      const segmentAngle = this.totalAngle / this.segmentCount;
      // 使用配置的分割距离比例
      const gapRatio = this.segmentGapRatio;
      const segSpan = segmentAngle * (1 - gapRatio); // 每段真正绘制的角度

      for (let i = 0; i < this.segmentCount; i++) {
        const segStart = this.startAngle + i * segmentAngle;
        const segEnd = segStart + segSpan;

        const segProgressStart = i / this.segmentCount;
        const segProgressEnd = (i + 1) / this.segmentCount;

        // 计算当前进度在该段内的占比 0~1
        let activeRatio = 0;
        if (this.progress >= segProgressEnd) {
          activeRatio = 1;
        } else if (this.progress > segProgressStart) {
          activeRatio = (this.progress - segProgressStart) / (segProgressEnd - segProgressStart);
        }

        const activeEndAngle = segStart + (segEnd - segStart) * activeRatio;

        // 绘制高亮部分
        if (activeRatio > 0) {
          ctx.setStrokeStyle(this.activeColor);
          ctx.beginPath();
          ctx.arc(this.centerX, this.centerY, this.radius, segStart, activeEndAngle, false);
          ctx.stroke();
        }

        // 绘制未高亮剩余部分
        if (activeRatio < 1) {
          ctx.setStrokeStyle(this.inactiveColor);
          ctx.beginPath();
          ctx.arc(this.centerX, this.centerY, this.radius, activeEndAngle, segEnd, false);
          ctx.stroke();
        }
      }
      

      ctx.restore();
      ctx.draw();
    },
    
    /**
     * 节流版本的绘制方法
     * @description 在拖动时使用,通过节流减少绘制频率,提升流畅度
     * @note 使用 requestAnimationFrame 确保绘制在下一帧执行,避免频繁绘制导致卡顿
     */
    drawFanThrottled() {
      // 如果已有待执行的绘制请求,取消它
      if (this._drawTimer !== null) {
        cancelAnimationFrame(this._drawTimer);
      }
      // 使用 requestAnimationFrame 节流绘制
      this._drawTimer = requestAnimationFrame(() => {
        this.drawFan();
        this._drawTimer = null;
      });
    },

    /**
     * 从事件中获取进度值并更新
     * @param {Object} e - 事件对象
     * @param {Boolean} updateProgress - 是否真正更新进度值(true:更新进度,false:只检查是否在有效区域内)
     * @param {Boolean} isDragging - 是否正在拖动中(拖动中会放宽触摸区域判断,提升流畅度)
     * @returns {Boolean} - 是否在有效区域内
     */
    getProgressFromEvent(e, updateProgress = true, isDragging = false) {
      // 安全检查:确保几何参数已初始化
      if (!this.totalAngle || this.totalAngle <= 0 || !this.radius || this.radius <= 0) {
        return false;
      }

      let clientX, clientY;
      if (e.touches && e.touches[0]) {
        clientX = e.touches[0].clientX;
        clientY = e.touches[0].clientY;
      } else if (e.changedTouches && e.changedTouches[0]) {
        clientX = e.changedTouches[0].clientX;
        clientY = e.changedTouches[0].clientY;
      } else if (typeof e.clientX === 'number') {
        clientX = e.clientX;
        clientY = e.clientY;
      } else {
        return false;
      }

      const canvasEl = this.$el && this.$el.querySelector ? this.$el.querySelector('#fanCanvas') : null;
      if (!canvasEl) return false;
      
      const rect = canvasEl.getBoundingClientRect ? canvasEl.getBoundingClientRect() : { left: 0, top: 0 };
      const x = clientX - rect.left;
      const y = clientY - rect.top;
      
      const dx = x - this.centerX;
      const dy = y - this.centerY;
      const dist = Math.hypot(dx, dy);
      
      // 计算触摸点的角度并归一化到 [0, 2π)
      let angle = Math.atan2(dy, dx);
      if (angle < 0) angle += Math.PI * 2;

      // 计算扇形的起始和结束角度
      const twoPi = Math.PI * 2;
      let start = this.startAngle % twoPi;
      if (start < 0) start += twoPi;
      const end = start + this.totalAngle;
      
      // 将角度调整到 [start, end] 范围内
      let normalizedAngle = angle;
      while (normalizedAngle < start) normalizedAngle += twoPi;
      
      // 检查角度是否在扇形范围内
      if (normalizedAngle > end) {
        return false;
      }
      
      // 非拖动状态:需要检查触摸区域(半径范围)
      if (!isDragging) {
        const innerRadius = this.radius - this.lineWidth * this.touchAreaInnerRatio;
        const outerRadius = this.radius + this.lineWidth * this.touchAreaOuterRatio;
        
        // 判断触摸点是否在触摸区域内(环形区域:内半径 < 距离 < 外半径)
        if (dist < innerRadius || dist > outerRadius) {
          // 检查是否在末端圆块附近
          const currentAngle = this.startAngle + this.totalAngle * this.progress;
          const endX = this.centerX + this.radius * Math.cos(currentAngle);
          const endY = this.centerY + this.radius * Math.sin(currentAngle);
          const distToEnd = Math.hypot(x - endX, y - endY);
          const endTolerance = this.lineWidth * this.touchAreaOuterRatio;
          if (distToEnd > endTolerance) {
            return false;
          }
        }
      }

      // 防止除以 0 或 NaN
      const newProgress = (normalizedAngle - start) / this.totalAngle;
      if (isNaN(newProgress) || !isFinite(newProgress)) {
        return false;
      }
      
      // 根据参数决定是否更新进度值
      if (updateProgress) {
        this.progress = Math.max(0, Math.min(1, newProgress));
        // 如果正在拖动,使用节流绘制提升流畅度;否则立即绘制
        if (isDragging) {
          this.drawFanThrottled();
        } else {
          this.drawFan();
        }
      }
      return true;
    },
    
    // 阻止事件默认行为和冒泡
    preventEvent(e) {
      if (e && e.preventDefault) e.preventDefault();
      if (e && e.stopPropagation) e.stopPropagation();
    },
    
    handleStart(e) {
      this.preventEvent(e);
      // 根据配置参数决定是否允许点击高亮
      if (this.enableClickHighlight) {
        const result = this.getProgressFromEvent(e, true);
        this.isDragging = result;
      } else {
        const result = this.getProgressFromEvent(e, false);
        this.isDragging = result && this.enableDragHighlight;
      }
    },
    
    handleMove(e) {
      this.preventEvent(e);
      if (this.isDragging && this.enableDragHighlight) {
        this.getProgressFromEvent(e, true, true);
      }
    },
    
    handleEnd(e) {
      this.preventEvent(e);
      this.isDragging = false;
    }
  }
}
</script>

<style>
.container {
  width: 100vw;
  height: 100vh;
  background: #141214;
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
}
.fan-canvas {
  touch-action: none;
}
.progress-text {
  position: absolute;
  top: 50%;
  left: 50%;
  
  transform: translate(-50%, -50%);
  color: #bfff00;
  font-size: 60rpx;
  font-weight: bold;
  background-color: pink;
  width: 280px;
  height: 280px;
  border-radius: 100%;
  text-align: center;
  line-height: 280px;
}

</style>
相关推荐
不爱学习小趴菜2 小时前
uniapp微信小程序无法屏蔽右上角胶囊按钮(...)问题解决方案
微信小程序·小程序·uni-app
比特森林探险记2 小时前
React基础:语法、组件与JSX
前端·javascript·react.js
宁雨桥2 小时前
Vue项目中iframe嵌入页面实现免登录的完整指南
前端·javascript·vue.js
css趣多多2 小时前
Elment UI 布局组件
javascript
WeiAreYoung2 小时前
uni-app Xcode制作iOS谷歌广告Google Mobile Ads SDK插件
ios·uni-app
无法长大2 小时前
Mac M1 环境下使用 Rust Tauri 将 Vue3 项目打包成 APK 完整指南
android·前端·macos·rust·vue3·tauri·打包apk
LongJ_Sir2 小时前
Cesium--可拖拽气泡弹窗(对话框尾巴,Vue3版)
前端·javascript·vue.js
im_AMBER2 小时前
消失的最后一秒:SSE 流式联调中的“时序竞争”
前端·笔记·学习·http·sse
GDAL2 小时前
Electron IPC 通信深入全面讲解教程
javascript·electron