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

鸿蒙手写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 水墨画效果

二、需求分析

  1. 数据展示:以条形图呈现 SDK 初始化各阶段时间,支持各个阶段时间对比,有利于后期分析SDK初始化过程中耗时问题。
  1. 手势交互:左右滑动查看更多数据,滑动有惯性;点击条形显示详细信息。
  1. 视觉效果:渐变条形、阴影,提升美观度。

三、技术基础

鸿蒙 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 和手势处理,优化性能和代码结构,打造流畅的用户体验。

相关推荐
陈奕昆1 小时前
4.3 HarmonyOS NEXT AI驱动的交互创新:智能助手、实时语音与AR/MR开发实战
人工智能·交互·harmonyos
lqj_本人13 小时前
鸿蒙OS&UniApp结合机器学习打造智能图像分类应用:HarmonyOS实践指南#三方框架 #Uniapp
机器学习·uni-app·harmonyos
哼唧唧_14 小时前
使用 React Native 开发鸿蒙运动健康类应用的高频易错点总结
react native·react.js·harmonyos·harmony os5·运动健康
二流小码农16 小时前
鸿蒙开发:loading动画的几种实现方式
android·ios·harmonyos
大胖子10117 小时前
HarmonyOS5ArkTS常见数据类型认识
harmonyos
大胖子10117 小时前
HarmonyOS5鸿蒙开发常用装饰器
harmonyos
大胖子10117 小时前
HarmonyOS5鸿蒙开发常用组件介绍
harmonyos
小镇梦想家18 小时前
鸿蒙NEXT-Flutter(1)
harmonyos
zhanshuo19 小时前
安卓→鸿蒙迁移实战:3步重构消息提示,解锁跨设备协同黑科技!
harmonyos
不爱吃糖的程序媛20 小时前
鸿蒙版Taro 搭建开发环境
华为·harmonyos·taro