鸿蒙手写ECharts_手势惯性(条形统计图)

一、引言
在移动应用开发中,数据可视化是提升用户体验的关键。条形统计图作为直观的数据展示方式,结合鸿蒙的 Canvas 绘图和手势交互,可打造出流畅的交互体验。本文将详细讲解如何利用鸿蒙 Canvas 实现带手势惯性的条形统计图,涵盖需求分析、技术实现、代码优化等内容。
以下是我在掘金平台撰写的各端手写ECharts系列文章,欢迎感兴趣的读者选择阅读学习
Harmony 端
鸿蒙手写ECharts_饼状图
web 端
前端都是手写ECharts ?
Android 端
Android自定义-曲线渐变填充
Android自定义-任意区域可点击的折线图
Android自定义-手势缩放折线图
Android自定义-手势滑动缩放渐变填充曲线折线图表
Jetpack-Compose基本布局
JetPack-Compose - 自定义绘制
JetPack-Compose - Flutter 动态UI?
JetPack-Compose UI终结篇
JetPack-Compose 水墨画效果
二、需求分析
- 数据展示:以条形图呈现 SDK 初始化各阶段时间,支持各个阶段时间对比,有利于后期分析SDK初始化过程中耗时问题。
- 手势交互:左右滑动查看更多数据,滑动有惯性;点击条形显示详细信息。
- 视觉效果:渐变条形、阴影,提升美观度。
三、技术基础
鸿蒙 Canvas 绘图
通过 CanvasRenderingContext2D 进行 2D 绘图,支持路径、渐变、阴影等操作,如:
TypeScript
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#ffd0eef5');
gradient.addColorStop(1, '#ff7a43fa');
context.fillStyle = gradient;
手势处理
使用 PanGesture 处理平移,onActionUpdate 处理滑动,onActionEnd 处理惯性:
初速度:event可以通过event.velocityX获取水平方向手势抬起时候的速度作为惯性滑动的初速度单位(px/s)。
TypeScript
.gesture(
PanGesture(this.panOption)
.onActionStart((event: GestureEvent) => {
//保证滑动过程中,再手势再次滑动时可以暂停滑动。
this.velocity = 0
})
.onActionUpdate((event: GestureEvent) => {
if (event) {
this.offsetX = this.positionX + event.offsetX
//手势像左滑动,滑动最大也就是绘制内容达到Y轴。
if (this.offsetX <= -this.maxXRight) {
//如果滑动超出Y轴,需要再次赋值,避免滑出边界
this.offsetX = -this.maxXRight
return
}
//手势向右滑动,滑动最大就是默认绘制的开始位置跟Y轴是重合的。绘制左端不能滑动到Y轴右边部分。
if (this.offsetX >= 0) {
this.offsetX = 0
return
}
this.drawCanvas();
}
})
.onActionEnd((event: GestureEvent) => {
this.velocity = Math.abs(event.velocityX)
this.positionX = this.offsetX//记录当前具体位置
//这里如果用户脱手应该是有继续滑动的惯性的。
let scale = 1
if (event.velocityX < 0) {
scale = -1
}
this.update(scale)
})
)
向左滑动的最大距离: 绘制内容的总宽度由变量maxXRight
表示。当内容向左滑动至其左边缘与屏幕坐标系的 X=0 位置(Y 轴)对齐时,达到左滑临界点,无法继续向左滑动。
js
if (this.offsetX <= -this.maxXRight) {
//如果滑动超出Y轴,需要再次赋值,避免滑出边界
this.offsetX = -this.maxXRight
return
}

向右滑动的最大距离: 需要明白,坐标系以向右(X 轴)、向上(Y 轴)为正方向,默认位置为0,即绘制内容左边和Y轴重合。在整个绘制内容手势滑动的位置 this.offsetX 应该保证大于-this.maxXRight 且 小于 0。结合下面动图理解,当向右边滑动到内容最右边和Y轴重合,就不能继续向右滑动,所以必须小于 0 。
js
if (this.offsetX >= 0) {
this.offsetX = 0
return
}

惯性滑动
惯性滑动在物理学中,因为有个初速度且在滑动过程中有个阻尼加速度,所以速度逐渐变为0,最终停止的过程。在Android等开发中,如果了解ListView等列表滑动的,对于惯性滑动的编写应该不难。
如下:
velocity : 用于记录惯性滑动过程中的速度
dampingFactor : 阻尼系数,用于速度递减因子。假设dampingFactor = 0 .8 速度是10,下次10 * 0.8 = 8 ; 8*0.8 = 6.8 ; 6.8 * 0.8 = 5.64 .....速度逐渐变小。
frameDuration: 每一帧的耗时,1000(毫秒) / 60 大约每帧耗时16.67 毫秒。在下面方法中setTimeout时间为frameDuration,即表示每frameDuration长时间间隔,会更新一次UI。
如下是一个简单惯性滑动的实现,update 不断的调用自己,直到初速度<=1时停止。
TypeScript
velocity = 0// 初速度(px/s)
dampingFactor: number = 0.8 // 阻尼系数
frameDuration: number = 1000 / 60 // 60 FPS,每帧约 16.67 毫秒
private update(scale: number) {
if (velocity <= 1) return;
//距离 = 速度*时间间隔(单位换算为秒)
const deltaX = velocity * (frameDuration / 1000);
offsetX += deltaX * scale;
// 边界处理
drawAdCanvas(); // 重绘
velocity *= dampingFactor;//速度因为阻尼逐步变小
setTimeout(() => update(scale), frameDuration);
}
四、代码实现
数据、组件结构
TypeScript
//所需数据结构
export class SDKInfo {
invocationPhase: string //各个阶段的说明
time: number //各个阶段的时间,时间是变量,也是主要的关键信息
constructor(invocationPhase: string, time: number) {
this.invocationPhase = invocationPhase;
this.time = time;
}
}
export class SDKInitData {
saveDate: string = ''
type: number = 0
sdkInfoData: Array<SDKInfo> = []
}
@Component
export struct SdkInitEchartsView {
@State dataArray: SDKInitData[] = [];
@State offsetX: number = 0;//记录绘制内容水平滑动变化距离
@State positionX: number = 0;//实时记录当前位置
private context: CanvasRenderingContext2D;
// 手势、绘图方法等
}
绘制 X、Y 轴坐标系
坐标系距离组件左右上下有一定的距离。对坐标系进行变换为方便操作的位置
js
private drawCanvas() {
let width = this.context.width;
let height = this.context.height;
this.context.clearRect(0, 0, width, height)
this.context.save()
//0、控制变量,后面作为参数
const marginLeft = 50;
const marginTop = 50;
const marginBottom = 50;
const scaleUnit = 20;
const barWidth = 20;
const strokeWidth = 2;
//1、变换坐标系
this.context.scale(1, -1);
this.context.translate(marginLeft, -height + marginBottom);
//2、绘制坐标轴
const x_width = scaleUnit * this.dataArray[0].sdkInfoData.length * this.dataArray.length + this.dataArray.length * barWidth;
this.maxXRight = x_width
const y_height = height - marginBottom - marginTop;
//绘制XY轴
this.drawYAxis(strokeWidth, y_height + marginTop / 2);
this.drawXAxis(Math.max(x_width, width), strokeWidth);
this.context.restore()
}
private drawYAxis(strokeWidth: number, y_height: number) {
this.context.beginPath();
this.context.lineTo(0, -strokeWidth / 2);
this.context.lineTo(0, y_height);
this.context.strokeStyle = '#80FBBC';
this.context.shadowBlur = 3; // 阴影模糊半径
this.context.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色(半透明黑色)
this.context.shadowOffsetX = -1; // 阴影水平偏移
this.context.shadowOffsetY = 1; // 阴影垂直偏移
this.context.lineWidth = strokeWidth;
this.context.stroke();
this.context.save()
this.context.scale(1, -1)
this.context.translate(0, -y_height)
this.context.fillStyle = '#fffaf5f5'
this.context.font = '28px Arial'
const unitTextY = "(ms)"
const unitTextMeasure = this.context.measureText(unitTextY)
this.context.fillText(unitTextY, -unitTextMeasure.width, 0)
this.context.restore()
}
private drawXAxis(x_width: number, strokeWidth: number) {
this.context.beginPath();
this.context.lineTo(x_width, 0);
this.context.strokeStyle = '#80FBBC';
this.context.shadowBlur = 3; // 阴影模糊半径
this.context.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色(半透明黑色)
this.context.shadowOffsetX = 1; // 阴影水平偏移
this.context.shadowOffsetY = -1; // 阴影垂直偏移
this.context.lineWidth = strokeWidth;
this.context.stroke();
}

绘制条形统计图
绘制过程,多多利用context.save和context.restore可以减少大量的计算。
js
private drawBarEcharts(strokeWidth: number, y_height: number, scaleUnit: number, barWidth: number) {
console.error("dataArray=" + this.dataArray.toString())
if (this.dataArray.length <= 0) {
return;
}
this.context.save();
this.context.translate(0, strokeWidth / 2);
const maxValue = getMax(this.dataArray)
const unitY = y_height / maxValue;
this.dataArray.forEach((value, index) => {
this.context.translate(scaleUnit, 0);
const basePosition = scaleUnit * (index + 1) + barWidth * this.dataArray[0].sdkInfoData.length * index;
const checkClick = (offset: number) => {
return this.onClickX - 50 > basePosition + barWidth * offset
&& this.onClickX - 50 < basePosition + barWidth * (offset + 1);
};
const maxHeight = y_height //可以变量
//背景绘制
this.context.save()
const bgPath = new Path2D()
bgPath.moveTo(0, 0)
const currentWidth = barWidth * this.dataArray[0].sdkInfoData.length * 1
bgPath.lineTo(currentWidth, 0)
bgPath.lineTo(currentWidth, maxHeight)
bgPath.lineTo(0, maxHeight)
bgPath.closePath()
this.context.strokeStyle = '#61cde9d6'
this.context.stroke(bgPath)
this.context.restore()
//遍历绘制Bar
for (let outIndex = 0; outIndex < value.sdkInfoData.length; outIndex++) {
const selected1 = checkClick(outIndex);
this.drawBar(new BarInfo(maxHeight, value.type, barWidth, unitY, value.sdkInfoData[outIndex].time, value.sdkInfoData[outIndex].invocationPhase,
selected1));
this.context.translate(barWidth, 0);
}
});
this.context.restore();
}
private drawBar(barInfo: BarInfo) {
//这里的30防止,耗时请求超出坐标系
const yHeight = Math.min(barInfo.unitY * barInfo.value, barInfo.maxValue + 10)
const yMaxHeight = barInfo.maxValue
//绘制所使用的时间
this.context.save()
this.context.scale(1, -1)
this.context.translate(0, -10)
this.context.font = '30px Arial'
this.context.fillStyle = '#ffffff'
const textMeasure = this.context.measureText(barInfo.value.toString())
this.context.fillText(barInfo.value.toString(), (barInfo.barWidth - textMeasure.width) / 2,
-yHeight - textMeasure.height / 2)
this.context.restore()
const pathBg = new Path2D()
pathBg.moveTo(0, 0);
pathBg.lineTo(barInfo.barWidth, 0);
pathBg.lineTo(barInfo.barWidth, yMaxHeight);
pathBg.lineTo(0, yMaxHeight);
pathBg.closePath();
const path = new Path2D()
path.moveTo(0, 0);
path.lineTo(barInfo.barWidth, 0);
path.lineTo(barInfo.barWidth, yHeight);
path.lineTo(0, yHeight);
path.closePath();
// 添加渐变颜色点(可根据需要调整颜色和位置)
if (barInfo.clickTrue) {
this.context.fillStyle = '#164c4c4c'
this.context.fill(pathBg);
const gradient = this.context.createLinearGradient(0, 0, 0, yHeight);
gradient.addColorStop(1, '#ff7a43fa'); // 底部颜色(深蓝色)
gradient.addColorStop(0, '#ffd0eef5'); // 顶部颜色(浅蓝色)
barInfo.fillStyle = gradient
this.context.fillStyle = barInfo.fillStyle
this.context.shadowBlur = 15; // 阴影模糊半径
this.context.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色(半透明黑色)
this.context.shadowOffsetX = 0; // 阴影水平偏移
this.context.shadowOffsetY = 5; // 阴影垂直偏移
this.context.fill(path);
} else {
const gradient = this.context.createLinearGradient(0, 0, 0, yHeight);
gradient.addColorStop(1, '#ff5ed094'); // 底部颜色(深蓝色)
gradient.addColorStop(0, '#ffd0f5e1'); // 顶部颜色(浅蓝色)
barInfo.fillStyle = gradient
this.context.fillStyle = barInfo.fillStyle
this.context.shadowBlur = 15; // 阴影模糊半径
this.context.shadowColor = 'rgba(0, 0, 0, 0.5)'; // 阴影颜色(半透明黑色)
this.context.shadowOffsetX = 0; // 阴影水平偏移
this.context.shadowOffsetY = 5; // 阴影垂直偏移
this.context.fill(path);
}
}

增加手势点击详情
点击时候,需要将点击坐标转换到绘制坐标系,做屏幕映射。可以自己画图理解,当然也可以看我之前自定义的文章,很多都涉及到了屏幕手势坐标映射屏幕坐标系。
js
private drawBarEchartsInfo(strokeWidth: number, y_height: number, scaleUnit: number, barWidth: number) {
if (this.dataArray.length <= 0) {
return;
}
this.context.save();
this.context.translate(0, strokeWidth / 2);
const maxValue = getMax(this.dataArray)
const unitY = y_height / maxValue;
this.dataArray.forEach((value, index) => {
this.context.translate(scaleUnit, 0);
const basePosition = scaleUnit * (index + 1) + barWidth * this.dataArray[0].sdkInfoData.length * index;
const checkClick = (offset: number) => {
return this.onClickX - 50 > basePosition + barWidth * offset
&& this.onClickX - 50 < basePosition + barWidth * (offset + 1);
};
// 1、调用-初始化之前【useAgent获取..】
const maxHeight = y_height //可以变量
for (let outIndex = 0; outIndex < value.sdkInfoData.length; outIndex++) {
const selected = checkClick(outIndex);
this.drawBarInfo(new BarInfo(maxHeight, value.type, barWidth, unitY, value.sdkInfoData[outIndex].time, value.sdkInfoData[outIndex].invocationPhase,
selected));
this.context.translate(barWidth, 0);
}
});
this.context.restore();
}
private drawBarInfo(barInfo: BarInfo) {
//这里的30防止,耗时请求超出坐标系
const yHeight = Math.min(barInfo.unitY * barInfo.value, barInfo.maxValue + 10)
const yMaxHeight = barInfo.maxValue
// 添加渐变颜色点(可根据需要调整颜色和位置)
if (barInfo.clickTrue) {
const gradient = this.context.createLinearGradient(0, 0, 0, yHeight);
gradient.addColorStop(1, '#ff7a43fa'); // 底部颜色(深蓝色)
gradient.addColorStop(0, '#ffd0eef5'); // 顶部颜色(浅蓝色)
barInfo.fillStyle = gradient
//绘制说明框,用于数据详情显示
this.context.font = '26px Arial'
const typeText = barInfo.type === 0 ? "启动类型:新装" : "启动类型:冷启"
const textMeasure = this.context.measureText(typeText)
const textMeasure2 = this.context.measureText("启动阶段:" + barInfo.info)
const textMeasure3 = this.context.measureText("启动耗时:" + barInfo.value.toString() + ' ms')
const maxWidth = Math.max(textMeasure.width, textMeasure2.width, textMeasure3.width)
const textRectPath = new Path2D()
textRectPath.moveTo(barInfo.barWidth + 5, yMaxHeight + textMeasure.height * 2 + 6);
textRectPath.lineTo(barInfo.barWidth + 5 + maxWidth + 15, yMaxHeight + textMeasure.height * 2 + 6);
textRectPath.lineTo(barInfo.barWidth + 5 + maxWidth + 15, yMaxHeight - textMeasure.height * 2);
textRectPath.lineTo(barInfo.barWidth + 5, yMaxHeight - textMeasure.height * 2);
textRectPath.closePath();
this.context.fillStyle = '#d0d9d8d8'
this.context.shadowBlur = 6; // 阴影模糊半径
this.context.shadowColor = 'rgba(0, 0, 0, 0.96)'; // 阴影颜色(半透明黑色)
this.context.shadowOffsetX = 1; // 阴影水平偏移
this.context.shadowOffsetY = -1; // 阴影垂直偏移
this.context.fill(textRectPath)
this.context.save()
this.context.scale(1, -1)
this.context.translate(0, -10)
this.context.fillStyle = '#ffffff'
this.context.fillText(typeText, barInfo.barWidth + 10, -yMaxHeight)
this.context.fillText("启动阶段:" + barInfo.info, barInfo.barWidth + 10,
-yMaxHeight + textMeasure.height + 3)
this.context.fillText("启动耗时:" + barInfo.value.toString() + ' ms', barInfo.barWidth + 10,
-yMaxHeight + textMeasure.height * 2 + 3 * 2)
this.context.restore()
}
点击之后,bar变蓝色渐变,且灰色弹窗样式的长条显示具体详情。

增加手势惯性滑动
绘制图水平方向不确定其长度,为了展示所有的数据,我们增加水平滑动,且增加惯性滑动效果。即在上面手势和惯性滑动的基础上,让绘制内容部分不断地进行context.translate(this.offsetX,0)的变换即可,绘制的内容在其不断变换的坐标系内,可手势滑动内容增加裁剪,避免页面内所有的内容都跟随手势滑动。
js
private drawCanvas() {
...省略
this.context.beginPath();
this.context.rect(strokeWidth / 1.5, -strokeWidth / 2, Math.max(x_width, width),
y_height + marginTop + strokeWidth / 2); // 定义裁剪矩形路径,有且只有此范围内的跟随手势运动
this.context.clip(); // 设置为裁剪区域
this.context.translate(this.offsetX, 0)
//3、绘制文字
//4、绘制条形状统计图
this.onClickX = this.onClickX - this.offsetX;
this.drawBarEcharts(strokeWidth, y_height, scaleUnit, barWidth);
//5、绘制详情
this.drawBarEchartsInfo(strokeWidth, y_height, scaleUnit, barWidth);
//x轴放最后,好覆盖BAR,且跟随手势
this.drawXAxis(Math.max(x_width, width), strokeWidth);
this.context.restore()
}

五、总结
本文通过鸿蒙 Canvas 实现了手势惯性条形统计图,涵盖数据展示、手势交互、视觉优化等。开发者可在此基础上,结合实际需求扩展功能,提升应用的数据可视化能力。关键在于合理利用 Canvas 绘图 API 和手势处理,优化性能和代码结构,打造流畅的用户体验。