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

相关推荐
遇到困难睡大觉哈哈5 小时前
HarmonyOS —— Remote Communication Kit 拦截器(Interceptor)高阶定制能力笔记
笔记·华为·harmonyos
遇到困难睡大觉哈哈6 小时前
HarmonyOS —— Remote Communication Kit 定制处理行为(ProcessingConfiguration)速记笔记
笔记·华为·harmonyos
氤氲息6 小时前
鸿蒙 ArkTs 的WebView如何与JS交互
javascript·交互·harmonyos
遇到困难睡大觉哈哈6 小时前
HarmonyOS支付接入证书准备与生成指南
华为·harmonyos
赵浩生6 小时前
鸿蒙技术干货10:鸿蒙图形渲染基础,Canvas绘图与自定义组件实战
harmonyos
赵浩生6 小时前
鸿蒙技术干货9:deviceInfo 设备信息获取与位置提醒 APP 整合
harmonyos
BlackWolfSky7 小时前
鸿蒙暂未归类知识记录
华为·harmonyos
L、2189 小时前
Flutter 与开源鸿蒙(OpenHarmony):跨平台开发的新未来
flutter·华为·开源·harmonyos
L、2189 小时前
Flutter 与 OpenHarmony 深度融合实践:打造跨生态高性能应用(进阶篇)
javascript·flutter·华为·智能手机·harmonyos