uni-app上没有你想要的仪表盘?别急,我们来手撕一个!

767 阅读4分钟

大家好,我是Ysh

有过微信小程序开发经验的同学们,或多或少都遇到过一些坑,并且考虑到后续的可复用性我们一般都会使用uni-app进行开发,在开发涉及仪表盘组件时,我们会发现他的可选项少得可怜。

为什么可直接使用的仪表盘那么少?

表盘组件通常用于像数据可视化或仪表显示等场景,这些需求在移动应用和跨平台应用中不像其他组件(如按钮、输入框、列表等)使用起来更频繁。因此,社区和开发者会较少关注这类特定用途的组件。

为什么要手撕一个仪表盘?

手写一个仪表盘组件,而不是去使用现有的第三方库,是基于多方面的考量,特别是在兼容性和可移植性方面。

1. 完全的控制权和定制性

  • 细粒度控制:手写组件可以精确控制其行为和样式,这在使用第三方组件时,尤其在文档不完善的情况下,可能难以实现。手撕仪表盘可以根据特定需求调整每一个细节,从动画到交互方式。
  • 高度定制:在特定应用中,可能需要仪表盘具备独特的视觉效果或特定的功能,这些需求通过修改第三方库实现起来会变得复杂或不可行,且易造成代码冗余。

2. 兼容性和移植性

  • 平台特定优化:在多平台开发环境(如 uni-app)中,不同的目标平台(iOS、Android、Web、各类小程序等)可能有不同的性能特征和限制。手写组件可以针对每个平台进行优化,确保最佳的性能和兼容性。
  • 避免依赖冲突:使用第三方库可能带来版本依赖问题,尤其是在跨平台框架中。自行实现仪表盘可以避免这些复杂的依赖和潜在的兼容性问题。

捋清仪表盘要如何搭建

让我们看图说话:

image.png

由上图的UI设计稿所画,我们可以这样设计仪表盘

image.png

理论可行,实践开始!

该实践仅对核心点进行讲解,有需要的小伙伴可在文章末尾复制全量代码

核心功能实现

动态进度绘制

动态绘制进度是本组件的核心功能。使用 uni.createCanvasContext 方法创建画布,并通过动态计算角度和坐标来绘制进度。通过定时器逐步增加显示的进度,达到动态变化的视觉效果。

代码分析

animateProgress(targetProgress) {
  let currentProgress = 0;
  const increment = 1; // 每帧增加的进度
  const interval = 50; // 每50ms更新一次
  const animation = setInterval(() => {
    if (currentProgress >= targetProgress) {
      clearInterval(animation); // 停止动画
    } else {
      currentProgress += increment;
      this.drawProgressWithCircle(currentProgress); // 使用当前进度绘制进度条
    }
  }, interval);
}

绘制逻辑

使用 drawProgressWithCircle 方法进行绘制。计算外圈、内圈和进度条的位置及角度,通过画圆的方式完成。

代码分析

drawProgressWithCircle(p) {
  let percent = p ? p : 0;
  const ctx = uni.createCanvasContext('progressCanvas', this);
  ...
  // 绘制进度
  ctx.beginPath();
  ctx.arc(circleCenterX, circleCenterY, outerRadius, 0.5 * Math.PI, angle, true);
  ctx.arc(circleCenterX, circleCenterY, innerRadius, angle, 0.5 * Math.PI, false);
  ctx.closePath();
  ctx.setFillStyle('#0977b4'); // 进度色
  ctx.fill();
  ...
  ctx.draw();
}

响应式设计

组件通过计算属性和侦听器响应式地处理传入的 retentionScore 属性变化,实现数据与视图的同步。

代码分析

watch: {
  retentionScore: {
    handler: function (newVal) {
      this.widthInPx = uni.upx2px(368);
      this.heightInPx = uni.upx2px(368);
      if (newVal && newVal !== '0') {
        this.animateProgress(newVal);
      } else {
        this.drawProgressWithCircle(0);
      }
    },
  },
},

结论

通过综合运用 Vue.js 框架的响应式特性、Canvas 绘图技术和动态效果处理,我们实现了一个既美观又实用的动态仪表盘组件,可以有效地在用户界面中展示和监控关键指标的变化。

附上全量代码 :

view为uni-app写法,可改为dev

<template>
  <view class="dash-board" :style="{ marginLeft }">
    <canvas canvas-id="progressCanvas" style="width: 368rpx; height: 390rpx"></canvas>
    <image
      class="content-img"
      :style="{
        width: '155rpx',
        height: '282rpx',
      }"
      :src="GaugeImg"
      mode="scaleToFill"
    />
    <view class="content">
      <p
        :style="{
          fontWeight: '500',
          fontSize: '64rpx',
          color: '#0377A7',
          textAlign: 'center',
        }"
      >
        {{
          retentionScore.toString().includes('.') ? retentionScore : `${retentionScore}.0`
        }}
      </p>
      <p
        :style="{
          fontWeight: '600',
          fontSize: '28rpx',
          color: '#333',
          textAlign: 'center',
        }"
      >
        容量保持率评分
      </p>
      <p
        :style="{
          fontWeight: '400',
          fontSize: '20rpx',
          color: '#999',
          textAlign: 'center',
        }"
      >
        (SOH健康度)
      </p>
    </view>
  </view>
</template>

<script>
import GaugeImg from 'image/gauge-img.png';

export default {
  name: 'DashBoard',
  data() {
    return {
      GaugeImg,
      // 假设基准宽度为750rpx,实际宽度需在mounted中计算
      widthInPx: 0,
      heightInPx: 0,
    };
  },
  props: {
    marginLeft: {
      type: String,
      default: () => '0',
    },
    retentionScore: {
      type: String,
      default: () => '0',
    },
  },
  mounted() {
    this.widthInPx = uni.upx2px(368);
    this.heightInPx = uni.upx2px(368);
    if (this.retentionScore && this.retentionScore !== '0') {
      this.animateProgress(this.retentionScore);
    } else {
      this.drawProgressWithCircle(0);
    }
  },
  watch: {
    // 监听 'retentionScore' 属性的变化
    retentionScore: {
      handler: function (newVal) {
        // 当 'retentionScore' 发生变化时,调用 'animateProgress' 方法
        // 并将新的值作为参数传递给这个方法
        this.widthInPx = uni.upx2px(368);
        this.heightInPx = uni.upx2px(368);
        if (newVal && newVal !== '0') {
          this.animateProgress(newVal);
        } else {
          this.drawProgressWithCircle(0);
        }
      },
    },
  },
  methods: {
    animateProgress(targetProgress) {
      let currentProgress = 0;
      const increment = 1; // 每帧增加的进度
      const interval = 50; // 每50ms更新一次
      const animation = setInterval(() => {
        if (currentProgress >= targetProgress) {
          clearInterval(animation); // 停止动画
        } else {
          currentProgress += increment;
          this.drawProgressWithCircle(currentProgress); // 使用当前进度绘制进度条
        }
      }, interval);
    },
    drawProgressWithCircle(p) {
      let percent = p ? p : 0;
      const ctx = uni.createCanvasContext('progressCanvas', this);

      // 进度条参数配置
      const outerRadius = (this.widthInPx / 2) * 0.92; // 外圆半径
      const innerRadius = (this.widthInPx / 2) * 0.83; // 内圆半径稍小于外圆
      const width = (outerRadius - innerRadius) / 2; // 进度条宽度
      const circleCenterX = this.widthInPx / 2; // 圆心x坐标
      const circleCenterY = this.heightInPx / 2; // 圆心y坐标

      // 计算白点的圆心坐标
      const middleRadius = (outerRadius + innerRadius) / 2; // 计算中间半径
      const angle = Math.PI / 2 - (percent / 10) * Math.PI; // 根据百分比计算角度
      const x = circleCenterX + middleRadius * Math.cos(angle); // 圆心x坐标加上在X轴上的偏移
      const y = circleCenterY + middleRadius * Math.sin(angle); // 圆心y坐标加上在Y轴上的偏移

      // 绘制进度条的背景
      ctx.beginPath();
      ctx.arc(
        circleCenterX,
        circleCenterY,
        outerRadius,
        0.5 * Math.PI,
        -0.5 * Math.PI,
        true,
      );
      ctx.arc(
        circleCenterX,
        circleCenterY,
        innerRadius,
        -0.5 * Math.PI,
        0.5 * Math.PI,
        false,
      );
      ctx.closePath();
      ctx.setFillStyle('#c6ddf5'); // 背景色
      ctx.fill();

      // 绘制进度
      ctx.beginPath();
      ctx.arc(circleCenterX, circleCenterY, outerRadius, 0.5 * Math.PI, angle, true);
      ctx.arc(circleCenterX, circleCenterY, innerRadius, angle, 0.5 * Math.PI, false);
      ctx.closePath();
      ctx.setFillStyle('#0977b4'); // 进度色
      ctx.fill();

      if (percent) {
        ctx.beginPath();
        ctx.arc(x, y, width, 0, 2 * Math.PI, false);
        ctx.setFillStyle('#0977b4');
        ctx.fill();
        ctx.beginPath();
        ctx.arc(x, y, width - uni.upx2px(3), 0, 2 * Math.PI, false);
        ctx.setFillStyle('#ffffff');
        ctx.fill();
      }

      ctx.beginPath();
      ctx.arc(circleCenterX, width * 2.8, width, 0, 2 * Math.PI, false);
      ctx.setFillStyle(`${percent === 100 ? '#0977b4' : '#c6ddf5'}`);
      ctx.fill();

      ctx.beginPath();
      ctx.arc(
        circleCenterX,
        circleCenterY * 2 - width * 2.8,
        width,
        0,
        2 * Math.PI,
        false,
      );
      ctx.setFillStyle(`${percent ? '#0977b4' : '#c6ddf5'}`);
      ctx.fill();

      ctx.draw();
    },
  },
};
</script>

<style lang="scss" scoped>
.dash-board {
  position: relative;
  width: 368rpx;
  height: 368rpx;
  border-radius: 50%;
  background: #ffffff;
  box-shadow: 0rpx 0rpx 50rpx 0rpx rgba(3, 119, 167, 0.1);
  .content-img {
    position: absolute;
    top: 45rpx;
    left: 177rpx;
  }
  .content {
    padding-top: 40rpx;
    position: absolute;
    top: 68rpx;
    left: 63rpx;
    width: 238rpx;
    height: 238rpx;
    border-radius: 50%;
    background: linear-gradient(
      180deg,
      rgba(3, 119, 167, 0) 0%,
      rgba(3, 119, 167, 0.06) 100%
    );
    border: 13rpx solid #fff;
    box-shadow: 0rpx 0rpx 32rpx 0rpx rgba(3, 119, 167, 0.1);
  }
}
</style>