开源 Arkts 鸿蒙应用 开发(十六)自定义绘图控件--波形图

文章的目的为了记录使用Arkts 进行Harmony app 开发学习的经历。本职为嵌入式软件开发,公司安排开发app,临时学习,完成app的开发。开发流程和要点有些记忆模糊,赶紧记录,防止忘记。

相关链接:

开源 Arkts 鸿蒙应用 开发(一)工程文件分析-CSDN博客

开源 Arkts 鸿蒙应用 开发(二)封装库.har制作和应用-CSDN博客

开源 Arkts 鸿蒙应用 开发(三)Arkts的介绍-CSDN博客

开源 Arkts 鸿蒙应用 开发(四)布局和常用控件-CSDN博客

开源 Arkts 鸿蒙应用 开发(五)控件组成和复杂控件-CSDN博客

开源 Arkts 鸿蒙应用 开发(六)数据持久--文件和首选项存储-CSDN博客

开源 Arkts 鸿蒙应用 开发(七)数据持久--sqlite关系数据库-CSDN博客

开源 Arkts 鸿蒙应用 开发(八)多媒体--相册和相机-CSDN博客

开源 Arkts 鸿蒙应用 开发(九)通讯--tcp客户端-CSDN博客

开源 Arkts 鸿蒙应用 开发(十)通讯--Http-CSDN博客

开源 Arkts 鸿蒙应用 开发(十一)证书和包名修改-CSDN博客

开源 Arkts 鸿蒙应用 开发(十二)传感器的使用-CSDN博客

开源 Arkts 鸿蒙应用 开发(十三)音频--MP3播放_arkts avplayer播放音频 mp3-CSDN博客

开源 Arkts 鸿蒙应用 开发(十四)线程--任务池(taskpool)-CSDN博客

开源 Arkts 鸿蒙应用 开发(十五)自定义绘图控件--仪表盘-CSDN博客

开源 Arkts 鸿蒙应用 开发(十六)自定义绘图控件--波形图-CSDN博客

开源 Arkts 鸿蒙应用 开发(十七)通讯--http多文件下载-CSDN博客

开源 Arkts 鸿蒙应用 开发(十八)通讯--Ble低功耗蓝牙服务器-CSDN博客

推荐链接:

开源 java android app 开发(一)开发环境的搭建-CSDN博客

开源 java android app 开发(二)工程文件结构-CSDN博客

开源 java android app 开发(三)GUI界面布局和常用组件-CSDN博客

开源 java android app 开发(四)GUI界面重要组件-CSDN博客

开源 java android app 开发(五)文件和数据库存储-CSDN博客

开源 java android app 开发(六)多媒体使用-CSDN博客

开源 java android app 开发(七)通讯之Tcp和Http-CSDN博客

开源 java android app 开发(八)通讯之Mqtt和Ble-CSDN博客

开源 java android app 开发(九)后台之线程和服务-CSDN博客

开源 java android app 开发(十)广播机制-CSDN博客

开源 java android app 开发(十一)调试、发布-CSDN博客

开源 java android app 开发(十二)封库.aar-CSDN博客

推荐链接:

开源C# .net mvc 开发(一)WEB搭建_c#部署web程序-CSDN博客

开源 C# .net mvc 开发(二)网站快速搭建_c#网站开发-CSDN博客

开源 C# .net mvc 开发(三)WEB内外网访问(VS发布、IIS配置网站、花生壳外网穿刺访问)_c# mvc 域名下不可訪問內網,內網下可以訪問域名-CSDN博客

开源 C# .net mvc 开发(四)工程结构、页面提交以及显示_c#工程结构-CSDN博客

开源 C# .net mvc 开发(五)常用代码快速开发_c# mvc开发-CSDN博客

本章内容主要演示了如何使自定义控件,通过画布实现一个心率监测应用,主要包含三个文件:Index.ets、HeartRate.ets和HeartRateGraph.ets。

1.工程结构

2.源码解析

3.演示效果

4.工程下载网址

一、工程结构,主要有标红部分4个文件

二、源码解析

2.1 Index.ets

这是应用的入口文件,主要功能:

创建一个简单的界面展示心率数据和心率图表

使用AppStorage存储心率数据

通过定时器模拟心率数据变化

代码如下:

复制代码
import HeartRateGraph from '../uicomponents/HeartRateGraph';

@Entry
@Component
struct Index {
  @State heartRate: number = 0;

  aboutToAppear() {
    // 初始化 AppStorage 中的 heartRate
    AppStorage.setOrCreate('heartRate', this.heartRate);

    // 定时更新
    setInterval(() => {
      this.heartRate = Math.floor(Math.random() * 60) + 60;
      AppStorage.setOrCreate('heartRate', this.heartRate);
    }, 1000);
  }

  build() {
    Column() {
      // 不需要传递 heartRate 参数
      HeartRateGraph({
        viewWidth: 360,
        viewHeight: 300
      })
        .width('100%')
        .height(300)

      Text(`当前心率: ${this.heartRate} bpm`)
        .fontSize(18)
    }
  }
}

2.2 HeartRate.ets

这是主要的心率展示页面:展示心率数据(最大值、最小值、平均值、当前值)

使用@StorageLink从AppStorage同步心率数据和窗口大小数据

通过@Watch装饰器监听数据变化

以下为代码:

复制代码
/*
 * Copyright (c) 2024 Huawei Device Co., Ltd.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { display, promptAction, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

import HeartRateGraph from '../uicomponents/HeartRateGraph';
const uiContext: UIContext | undefined = AppStorage.get('uiContext');
const HEIGHT_NAVIGATION_BAR = 64;
const HEIGHT_TOP_ROW = 40;
const MARGIN_HORIZONTAL = 60;
const HEART_RATE_TOO_LOW = 50;
const HEART_RATE_TOO_HIGH = 100;
const context: Context =uiContext!.getHostContext()!;

@Component
export struct HeartRate {
  @StorageLink('heartRate') @Watch('onHeartRate') heartRate: number = 0;
  @StorageLink('windowSize') @Watch('onWindowSizeChange') windowSize: window.Size = {
    width: display.getDefaultDisplaySync().width,
    height: display.getDefaultDisplaySync().height
  };
  @State isFoldAble: boolean = false;
  @State foldStatus: number = 2;
  @State heartRateTop: number = 0;
  @State heartRateBottom: number = 0;
  @State heartRateAverage: number = 0;
  @State graphWidth: number = this.windowSize.width - this.getUIContext().vp2px(MARGIN_HORIZONTAL) * 2;
  @State graphHeight: number = this.windowSize.height - this.getUIContext().vp2px(HEIGHT_NAVIGATION_BAR) - this.getUIContext().vp2px(HEIGHT_TOP_ROW * 3);
  @State isConnect: boolean = false;
  private windowClass = (context as common.UIAbilityContext).windowStage.getMainWindowSync();

  onHeartRate(): void {
    this.heartRateTop = this.heartRateTop < this.heartRate ? this.heartRate : this.heartRateTop;
    this.heartRateBottom = (this.heartRateBottom === 0) ? this.heartRate :
      (this.heartRateBottom > this.heartRate) ? this.heartRate : this.heartRateBottom;
    this.heartRateAverage = (this.heartRateAverage + this.heartRate) / 2;
  }

  onWindowSizeChange(): void {
    this.graphWidth = this.windowSize.width - this.getUIContext().vp2px(MARGIN_HORIZONTAL) * 2;
    this.graphHeight = this.windowSize.height - this.getUIContext().vp2px(HEIGHT_NAVIGATION_BAR) - this.getUIContext().vp2px(HEIGHT_TOP_ROW * 3);
  }

  showWarningReminder(): boolean {
    return this.tooHigh() || this.tooLow();
  }

  tooHigh(): boolean {
    return this.heartRate > HEART_RATE_TOO_HIGH;
  }

  tooLow(): boolean {
    return this.heartRate < HEART_RATE_TOO_LOW;
  }

  setOrientation(orientation: number) {
    this.windowClass.setPreferredOrientation(orientation).then(() => {
      //Logger.info('setWindowOrientation Succeeded');
    }).catch((err: BusinessError) => {
      //Logger.error(`setWindowOrientation Failed. Cause:${JSON.stringify(err)}`);
    })
    this.windowClass.setWindowSystemBarEnable([]);
  }
  aboutToAppear() {
    this.setOrientation(window.Orientation.LANDSCAPE);
    let mWindow: window.Window | undefined;
    let windowStage: window.WindowStage | undefined;
    mWindow = windowStage?.getMainWindowSync();
    mWindow?.on('windowSizeChange', (size: window.Size) => {
      AppStorage.setOrCreate('windowSize', size);
    })

    this.isFoldAble = display.isFoldable();
    let foldStatus: display.FoldStatus = display.getFoldStatus();
    if (this.isFoldAble) {
      this.foldStatus = foldStatus;
      let callback: Callback<number> = () => {
        let data: display.FoldStatus = display.getFoldStatus();
        this.foldStatus = data;
      }
      display.on('change', callback);
    }

  }

  aboutToDisappear(): void {
    this.setOrientation(window.Orientation.PORTRAIT);
  }

  build() {
    NavDestination() {


      Row() {
        Row() {
          Image($r('app.media.heart_fill'))
            .width(36)
            .aspectRatio(1)
            .margin({ left: 8 })
          Column() {
            Row() {
              Text(`${this.heartRateTop}`)
                .fontColor(Color.Black)
                .opacity(0.9)
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
              Text($r('app.string.times_per_minute'))
                .fontColor(Color.Black)
                .opacity(0.6)
            }
            .width(90)

            Text($r('app.string.maximum_heart_rate'))
              .fontColor(Color.Black)
              .opacity(0.6)
              .width(90)
          }
          .margin({ left: 30, right: 50 })

          Column() {
            Row() {
              Text(`${this.heartRateBottom}`)
                .fontColor(Color.Black)
                .opacity(0.9)
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
              Text($r('app.string.times_per_minute'))
                .fontColor(Color.Black)
                .opacity(0.6)
            }
            .width(90)

            Text($r('app.string.minimum_heart_rate'))
              .fontColor(Color.Black)
              .opacity(0.6)
              .width(90)
          }
        }
        .alignItems(VerticalAlign.Center)
        .width(this.isFoldAble && this.foldStatus === 2 ? 345 : 360)
        .height(90)
        .backgroundColor(Color.White)
        .borderRadius(12)
        .margin({ right: 24 })

        Row() {
          Image($r('app.media.waveform_path_ecg_heart_fill'))
            .width(36)
            .aspectRatio(1)
            .margin({ left: 8 })
          Column() {
            Row() {
              Text(`${Math.floor(this.heartRateAverage)}`)
                .fontColor(Color.Black)
                .opacity(0.9)
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
              Text($r('app.string.times_per_minute'))
                .fontColor(Color.Black)
                .opacity(0.6)
            }
            .width(90)

            Text($r('app.string.mean_heart_rate'))
              .fontColor(Color.Black)
              .opacity(0.6)
              .width(90)
          }
          .margin({ left: 30, right: 50 })

          Column() {
            Row() {
              Text(`${this.heartRate}`)
                .fontColor(Color.Black)
                .opacity(0.9)
                .fontSize(26)
                .fontWeight(FontWeight.Bold)
              Text($r('app.string.times_per_minute'))
                .fontColor(Color.Black)
                .opacity(0.6)
            }
            .width(90)

            Text($r('app.string.current_heart'))
              .fontColor(Color.Black)
              .opacity(0.6)
              .width(90)
          }
        }
        .alignItems(VerticalAlign.Center)
        .width(this.isFoldAble && this.foldStatus === 2 ? 345 : 360)
        .height(90)
        .backgroundColor(Color.White)
        .borderRadius(12)
      }
      .width('100%')
      .height(90)
      .padding({ left: 60, right: 60 })
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 36 })

      HeartRateGraph({
        viewWidth: 756,
        viewHeight: 180
      })
    }
    .hideTitleBar(true)
    .backgroundColor('#F5F5F5')
  }
}

2.3 HeartRateGraph.ets

这是心率图表组件,主要功能:绘制心率变化曲线图,显示坐标轴和时间轴,响应视图大小变化

使用CanvasRenderingContext2D进行绘图,绘制坐标轴、网格线和心率曲线

以下为代码:

复制代码
import DateUtils from '../utils/DateUtils';
const uiContext: UIContext | undefined = AppStorage.get('uiContext');
const SIZE: number = 12;
const COORDINATE_SIZE: number = 5;
const LINE_WIDTH: number = uiContext!.px2vp(6);
const COLOR_LINE: string = '#FF0A59F7';
const MAX_HEART_RATE: number = 200;
const WIDTH_CHANGE_POINT: number = uiContext!.px2vp(900);

const X_COORDINATE_TEXT_HEIGHT: number = uiContext!.px2vp(30);
const START_X: number = uiContext!.px2vp(40);
const PADDING_VERTICAL: number = uiContext!.px2vp(40);
const PADDING_HORIZONTAL: number = uiContext!.px2vp(40);


// 在常量定义部分增加字体大小相关常量
const COORDINATE_FONT_SIZE: number = uiContext!.px2vp(18); // 坐标轴字体大小
const DATA_LABEL_FONT_SIZE: number = uiContext!.px2vp(16); // 数据标签字体大小
const TITLE_FONT_SIZE: number = uiContext!.px2vp(15);     // 标题字体大小

@Component
export default struct HeartRateGraph {
  @StorageProp('heartRate') @Watch('onHeartRate') heartRate: number = 0;
  @Prop @Watch('onViewSizeChange') viewWidth: number;
  @Prop @Watch('onViewSizeChange') viewHeight: number;
  @State heartRateArr: Array<number> = [];
  @State timeArr: Array<string> = [];
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private mCoordinateLineEndX: number = 0;
  private mOffset: number = 0;
  private mHeightRatio: number = 0;
  private mHeartRateCoordinateArr: Array<number> = [];

  aboutToAppear(): void {
    this.calculateLayoutConfig();

    for (let i = 0; i < SIZE; i++) {
      this.heartRateArr[i] = 0;
    }
    for (let i = 0; i < SIZE; i++) {
      this.timeArr[i] = `--:--:--`;
    }
  }

  onHeartRate() {
    this.heartRateArr.push(this.heartRate);
    if (this.heartRateArr.length > SIZE) {
      this.heartRateArr.shift();
    }

    this.timeArr.push(DateUtils.format(new Date(), 'HH:mm:ss'));
    if (this.timeArr.length > SIZE) {
      this.timeArr.shift();
    }
    this.draw();
  }

  onViewSizeChange() {
    this.calculateLayoutConfig();
  }

  calculateLayoutConfig() {
    this.mCoordinateLineEndX = this.viewWidth - PADDING_HORIZONTAL;
    this.mOffset = (this.viewWidth - START_X - PADDING_HORIZONTAL * 2) / (SIZE - 1);
    this.mHeightRatio = (this.viewHeight - PADDING_VERTICAL * 2 - X_COORDINATE_TEXT_HEIGHT * 2) / MAX_HEART_RATE;
    let heartRateCoordinate: number = MAX_HEART_RATE / (COORDINATE_SIZE - 1);
    this.mHeartRateCoordinateArr =
      [0, heartRateCoordinate, heartRateCoordinate * 2, heartRateCoordinate * 3, heartRateCoordinate * 4];
  }

  draw() {
    this.context.clearRect(0, 0, this.viewWidth, this.viewHeight);
    this.drawCoordinate();
    this.drawHeartRateLine();
  }

  drawCoordinate() {


    // 修改坐标轴文字大小和样式
    this.context.font = `${COORDINATE_FONT_SIZE}px sans-serif`;
    this.context.fillStyle = '#333333'; // 使用更深的颜色提高可读性

    // 修改数据标签文字大小
    this.context.font = `${DATA_LABEL_FONT_SIZE}px sans-serif`;
    this.context.fillStyle = '#000000'; // 黑色提高对比度


    this.context.lineWidth = LINE_WIDTH / 3;
    this.context.font = '16px sans-serif';

    let path: Path2D = new Path2D();
    this.context.fillStyle = '#999999';
    for (let i = 0; i < COORDINATE_SIZE; i++) {
      let text = `${this.mHeartRateCoordinateArr[i]}bpm`;
      let offsetY = (this.viewHeight - PADDING_VERTICAL * 2 - X_COORDINATE_TEXT_HEIGHT * 2) / (COORDINATE_SIZE - 1);
      let x = 0;
      let y = this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2 - offsetY * i +
        this.context.measureText(text)?.height / 4;
      path.moveTo(x, y);
      this.context.fillText(text, x, y);
      this.context.stroke(path);
    }
    this.context.strokeStyle = '#1A000000';
    for (let i = 0; i < COORDINATE_SIZE; i++) {
      let offsetY = (this.viewHeight - PADDING_VERTICAL * 2 - X_COORDINATE_TEXT_HEIGHT * 2) / (COORDINATE_SIZE - 1);
      let x = START_X + PADDING_HORIZONTAL;
      let y = this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2 - offsetY * i;
      let path1: Path2D = new Path2D();
      path1.moveTo(x, y);
      path1.lineTo(this.mCoordinateLineEndX, y);
      this.context.stroke(path1);
    }

    this.context.fillStyle = '#999999';
    let path2: Path2D = new Path2D();
    for (let i = 0; i < SIZE; i++) {
      if (this.viewWidth <= WIDTH_CHANGE_POINT && i % 2 === 0) {
        continue;
      }
      let text = this.timeArr[i];
      let x = START_X + this.mOffset * i + PADDING_HORIZONTAL - this.context.measureText(text)?.width / 2;
      let y = this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT;
      path2.moveTo(x, y);
      this.context.fillText(text, x, y);
      this.context.stroke(path2);
    }

    this.context.fillStyle = '#333333';
    let path3: Path2D = new Path2D();
    for (let i = 0; i < SIZE; i++) {
      if (this.viewWidth <= WIDTH_CHANGE_POINT && i % 2 === 0) {
        continue;
      }
      let text = `${this.heartRateArr[i]}bpm`;
      let x = START_X + this.mOffset * i + PADDING_HORIZONTAL - this.context.measureText(text)?.width / 2;
      let y = this.viewHeight - PADDING_VERTICAL;
      path2.moveTo(x, y);
      this.context.fillText(text, x, y);
      this.context.stroke(path3);
    }
  }

  drawHeartRateLine() {
    this.context.lineWidth = LINE_WIDTH;
    this.context.strokeStyle = COLOR_LINE;

    let path: Path2D = new Path2D();
    for (let i = 0; i < SIZE; i++) {
      let x = START_X + this.mOffset * i + PADDING_HORIZONTAL;
      let y =
        this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2 - this.heartRateArr[i] * this.mHeightRatio;
      if (i === 0) {
        path.moveTo(x, this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2);
        path.lineTo(x, y);
      } else {
        path.lineTo(x, y);
      }

      if (i === SIZE - 1) {
        path.lineTo(x, this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2);
      }
    }
    let gradient = this.context.createLinearGradient(0, PADDING_VERTICAL, 0,
      this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2);
    gradient.addColorStop(0.0, '#660A59F7');
    gradient.addColorStop(1.0, '#660A59F7');
    this.context.fillStyle = gradient;
    this.context.fill(path);
    this.context.stroke(path);

    this.context.clearRect(START_X + PADDING_HORIZONTAL - LINE_WIDTH / 2, 0, LINE_WIDTH,
      this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2 + LINE_WIDTH / 2);
    this.context.clearRect(this.viewWidth - PADDING_HORIZONTAL - LINE_WIDTH / 2, 0, LINE_WIDTH,
      this.viewHeight - PADDING_VERTICAL - X_COORDINATE_TEXT_HEIGHT * 2 + LINE_WIDTH / 2);
  }

  build() {

    Column() {
      // 添加图表标题
      Text('心率变化图')
        .fontSize(TITLE_FONT_SIZE)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ bottom: 10 });


      Canvas(this.context)
        .width(this.viewWidth)
        .height(this.viewHeight)
        .onReady(() => {
          this.draw();
        })
    }
  }
}

2.4 DateUtils.ets时间格式化文件

以下为代码:

复制代码
const MAX_LENGTH: number = 2;

export default class DateUtils {
  public static format(date: Date, format: string = 'yyyy-MM-dd HH:mm:ss'): string {
    let year = date.getFullYear().toString();
    let month = (date.getMonth() + 1).toString().padStart(MAX_LENGTH, '0');
    let day = (date.getDate() + 1).toString().padStart(MAX_LENGTH, '0');
    let hour = (date.getHours() + 1).toString().padStart(MAX_LENGTH, '0');
    let minute = (date.getMinutes() + 1).toString().padStart(MAX_LENGTH, '0');
    let second = (date.getSeconds() + 1).toString().padStart(MAX_LENGTH, '0');
    let result = format.replace('yyyy', year);
    result = result.replace('MM', month);
    result = result.replace('dd', day);
    result = result.replace('HH', hour);
    result = result.replace('mm', minute);
    result = result.replace('ss', second);
    return result
  }
}

三、演示效果

四、项目源码下载网址:https://download.csdn.net/download/ajassi2000/91681323

相关推荐
前端世界37 分钟前
HarmonyOS 实战:用 @Observed + @ObjectLink 玩转多组件实时数据更新
华为·harmonyos
zhanshuo1 小时前
HarmonyOS 实战:从输入框到完整表单,教你一步步搞定用户输入处理
harmonyos
zhanshuo1 小时前
在鸿蒙应用中快速接入地图功能:从配置到实战案例全解析
harmonyos
monster_风铃3 小时前
华为实验 链路聚合
网络·华为
前端世界4 小时前
鸿蒙任务调度机制深度解析:优先级、时间片、多核与分布式的流畅秘密
分布式·华为·harmonyos
A尘埃4 小时前
金融项目高可用分布式TCC-Transaction(开源框架)
分布式·金融·开源
小小小小小星7 小时前
鸿蒙开发之ArkUI框架进阶:从声明式范式到跨端实战
harmonyos·arkui
鸿蒙小灰7 小时前
鸿蒙开发对象字面量类型标注的问题
harmonyos
鸿蒙先行者7 小时前
鸿蒙Next不再兼容安卓APK,开发者该如何应对?
harmonyos