大家好,我是Ysh
有过微信小程序开发经验的同学们,或多或少都遇到过一些坑,并且考虑到后续的可复用性我们一般都会使用uni-app进行开发,在开发涉及仪表盘组件时,我们会发现他的可选项少得可怜。
为什么可直接使用的仪表盘那么少?
表盘组件通常用于像数据可视化或仪表显示等场景,这些需求在移动应用和跨平台应用中不像其他组件(如按钮、输入框、列表等)使用起来更频繁。因此,社区和开发者会较少关注这类特定用途的组件。
为什么要手撕一个仪表盘?
手写一个仪表盘组件,而不是去使用现有的第三方库,是基于多方面的考量,特别是在兼容性和可移植性方面。
1. 完全的控制权和定制性
- 细粒度控制:手写组件可以精确控制其行为和样式,这在使用第三方组件时,尤其在文档不完善的情况下,可能难以实现。手撕仪表盘可以根据特定需求调整每一个细节,从动画到交互方式。
- 高度定制:在特定应用中,可能需要仪表盘具备独特的视觉效果或特定的功能,这些需求通过修改第三方库实现起来会变得复杂或不可行,且易造成代码冗余。
2. 兼容性和移植性
- 平台特定优化:在多平台开发环境(如
uni-app
)中,不同的目标平台(iOS、Android、Web、各类小程序等)可能有不同的性能特征和限制。手写组件可以针对每个平台进行优化,确保最佳的性能和兼容性。 - 避免依赖冲突:使用第三方库可能带来版本依赖问题,尤其是在跨平台框架中。自行实现仪表盘可以避免这些复杂的依赖和潜在的兼容性问题。
捋清仪表盘要如何搭建
让我们看图说话:
由上图的UI设计稿所画,我们可以这样设计仪表盘
理论可行,实践开始!
该实践仅对核心点进行讲解,有需要的小伙伴可在文章末尾复制全量代码
核心功能实现
动态进度绘制
动态绘制进度是本组件的核心功能。使用 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>