华为鸿蒙都是手写ECharts ?

ECharts作为前端强大的图表、K线、地图等封装库可以说无比风骚。但用户和产品的需求永远是一个库满足不了的,除非产品和设计的基础是在图表库基础上进行。我们前端移动端作为产品的排面就应该让其独具特色,别具一格。所以自定义从产品设计技术岗位亿万用户不同需求...出发,"自定义极其重要"。

一、自定义

今天看了看ArkTs对绘制API的封装,可谓是和JS一模一样。大家根据官方API粗略浏览。至于自定义从零基础到高手,我相信只要花费一下午时间,阅读、练习、理解 前端都是手写ECharts ?就足够了,学不会我手把手教你。

下面是我在掘金自定义相关的文章大家可以选择学习:

前端都是手写ECharts ?

Android自定义-曲线渐变填充
Android自定义-任意区域可点击的折线图
Android自定义-手势缩放折线图
Android自定义-手势滑动缩放渐变填充曲线折线图表

Jetpack-Compose基本布局
JetPack-Compose - 自定义绘制
JetPack-Compose - Flutter 动态UI?
JetPack-Compose UI终结篇
JetPack-Compose 水墨画效果

二、需求分析

最近项目中产品要求扇形展示票务的所占比例。产品不知道哪里拿了截图,截图如下,说实话有丑到我。于是花了一下午时间实现了一个比较好看的效果。支持了【手势旋转,指示器跟随滑动,文字分段,点击选中显示指示器等效果】

  1. 我让产品和设计加上手势转动,他们觉得如果可以,更好。
  2. 文字过长问题,我说可以限制显示区域,分行显示,他们觉得能实现更好。
  1. 票务类型过多,避免不了重叠问题,即使分行显示,我说要不手势触摸相应区域让其对应文字出现,其他隐藏,他们觉得这也太棒了。

触摸到对应区域,显示当前区域文字指示器和对称指示器。而不是全部显示避免重叠。

大概效果如下:

在技术角度来看,我们需要的是基本的绘制API,和手势旋转相关的API。使用的语言是ArkTS。
官方画布基础API地址
官方手势基础API地址

四、编写代码

1、项目创建

创建一个ArkTS项目,新建页面CanvasPage.ets并根据官方画布基础API进行简单的扇形区域绘制。觉得难的先看完前端都是手写ECharts ?基础绘制。

js 复制代码
@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .onReady(() => {
          let width = this.context.width
          let height = this.context.height

          //将坐标圆心变换到屏幕中心即圆心,方便操作
          this.context.translate(width / 2, height / 2)
          //绘制开始,每次绘制不同内容,都需要开始和之前的绘制不在交织。
          this.context.beginPath()
          //开始绘制地方
          this.context.moveTo(0, 0)
          //在屏幕中心(0,0)绘制一个半径为100像素的扇形区域,扇形角度为45度。可以看到水平X轴是扇形弧度开始地方。顺时针绘制。
          this.context.arc(0, 0, 100, 0, Math.PI * 0.25)
          //关闭路径,这样弧度结尾会自动连接起时的圆心区域
          this.context.closePath()
          
          this.context.fillStyle = 'rgb(111,212,124)'
          this.context.fill()
        })
    }
    .height('100%')
  }
}

2、数据绘制

创建数据类,初始化一个数据集合

js 复制代码
class CirclePieChartMaxBean {
  valueData: number//票数
  title: string//指示器标题
  sub: string//指示器副标题
  color: ResourceColor | string//扇形区域颜色

  constructor(valueData: number, title: string, sub: string, color: ResourceColor | string) {
    this.valueData = valueData
    this.title = title
    this.sub = sub
    this.color = color
  }
}
js 复制代码
let dataList: Array<CirclePieChartMaxBean> = [
  new CirclePieChartMaxBean(
    30,
    "Compose",
    "60%",
    'rgba(53,158,255,1.00)'
  ),
  new CirclePieChartMaxBean(
    30,
    "Flutter",
    "30%",
    'rgba(67, 223, 210, 1.00)'
  ),
  new CirclePieChartMaxBean(
    10,
    "ArkTS",
    "5%",
    'rgba(255, 212, 81, 1.00)'
  ),
  new CirclePieChartMaxBean(
    20,
    "Xml",
    "3%",
    'rgba(155, 115, 226, 1.00)'
  ),
  new CirclePieChartMaxBean(
    10,
    "Vue",
    "10%",
    'rgba(239, 184, 200, 1.00)'
  )
]

首先自定义绘制的是用户需求的数据,而数据并非角度,用户的数据和角度如何进行映射是关键。我们知道一个扇形统计图一周的角度是360度,即对应数据的总和。而每一个语言数据所占的比例不难求出(某语言类型数据/所有语言总和)。从而可以求出每一份语言所占的角度公式 = 某语言数/语言总和。

我们不难求出每个编程语言所对应的角度。

js 复制代码
import { dataList } from '../data/model/CirclePieChartMaxBean'


@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .onReady(() => {
          let width = this.context.width
          let height = this.context.height
          this.context.translate(width / 2, height / 2)
          this.context.beginPath()
          this.context.moveTo(0, 0)
          this.context.arc(0, 0, 100, 0, Math.PI * 0.25)
          this.context.closePath()
          this.context.fillStyle = 'rgb(111,212,124)'
          this.context.fill()
          
          //求和
          let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
          for (let index = 0; index < dataList.length; index++) {
            const angleData = dataList[index];
            let sweepAngle = angleData.valueData / sum * 360
            console.log("sweepAngle",sweepAngle.toString())
          }
        })
    }
    .height('100%')
  }
}

打印结果

js 复制代码
05-17 16:41:59.335    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 108
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 108
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 36
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 72
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 36

已经知道角度如何绘制?我们需要明确,每次绘制都必须在上一个绘制的弧度结尾接着绘制。所以需要每次绘制完记录一下绘制结尾的弧度。下次绘制在这个弧度基础上绘制。

js 复制代码
@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .onReady(() => {
          let radius = 80
          //记录上一个绘制结束的角度。
          let routeAngle = 0

          let width = this.context.width
          let height = this.context.height
          this.context.translate(width / 2, height / 2)

          let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
          for (let index = 0; index < dataList.length; index++) {
            const angleData = dataList[index];
            //每个扇形区域
            let sweepAngle = angleData.valueData / sum * 360
            console.log("sweepAngle",sweepAngle.toString())

            //绘制扇形区域开始
            this.context.save()
            //我习惯垂直方向向上作为弧度绘制开始,而不喜欢水平,这个应该由产品决定,而我这里选择了-90到了12点位置作为绘制起始
            //为了让扇形每一份都朝着自己弧度中心向外traslate,所以我这里让会让坐标系横轴X转到每个扇形弧度中间,接着直接translate(间隙距离,0)就可以达到间隙效果。
            let rotateAngle = (routeAngle + sweepAngle / 2 - 90)
            this.context.rotate(rotateAngle / 180 * Math.PI)
            //这个让绘制坐标系每次偏离5像素,最终会让每个扇形之间有间隙。
            this.context.translate(5, 0)
            this.context.beginPath()
            this.context.moveTo(0, 0)
            //记得测试X方向如下图二,所以-sweepAngle/2即12点钟方向。测试坐标系0角度即x轴方向。这里好好理解一下,理解不了去看之前的文章。
            this.context.arc(0, 0, radius, Math.PI * (-sweepAngle / 2 / 180), Math.PI * (sweepAngle / 2 / 180))
            this.context.closePath()
            this.context.fillStyle = angleData.color.toString()
            this.context.shadowBlur = 10
            this.context.shadowOffsetX = 1
            this.context.shadowColor = 'rgb(0,0,0)'
            this.context.fill()
            this.context.restore()

            //每绘制完一个区域,都需要在之前基础上加上次角度,便于后面在此之上接着绘制
            routeAngle += sweepAngle
          }

        })
    }
    .height('100%')
  }
}

弧度绘制完成。

3、指示线绘制

在上面旋转到弧度中间的基础上我们利用勾股定理,进行计算线的位置。不难求出A点、B点、C点位置。这里需要知道文字长宽策略API为: let textMeasure = this.context.measureText(angleData.title),开发者可以通过textMeasure.height和textMeasure.width获取文字的长度来得到C点的坐标,用于绘制文字线。

js 复制代码
private drawTextLine(radius: number, halfAngle: number, angleData: CirclePieChartMaxBean) {
  //上图A点
  const pointToCircle = 20
  const titleLineToPDistance = 20
  const mTitleMarginStar = 10
  let centerX = (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
  let centerY = -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
  let lineTwoPointX = (radius + pointToCircle + titleLineToPDistance) * Math.sin(
    this.degreesToRadians(
      halfAngle + 90
    )
  )
  let lineTwoPointY = -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
    this.degreesToRadians(
      halfAngle + 90
    )
  )
  //计算文字长度
  let textTitleMeasure = this.context.measureText(angleData.title)
  let textSubMeasure = this.context.measureText(angleData.sub)

  this.context.beginPath()
  this.context.moveTo(centerX, centerY)
  this.context.lineTo(lineTwoPointX, lineTwoPointY)
  this.context.lineTo(
    lineTwoPointX + textTitleMeasure.width + mTitleMarginStar,
    lineTwoPointY
  )

  let textDrawStartX = lineTwoPointX + mTitleMarginStar
  let textSubStartX = lineTwoPointX + textTitleMeasure.width + mTitleMarginStar - textSubMeasure.width

  this.context.lineWidth = 1
  this.context.shadowBlur = 4
  this.context.shadowOffsetY = 0.5
  this.context.shadowColor = 'rgb(0,0,0)'
  this.context.strokeStyle = 'rgba(53,158,255,1.00)'
  this.context.stroke()
}

不难发现在第一象限和第二象限是没啥大问题,但是三四象限貌似反向折叠了。所以我们需要不同象限进行绘制,如果花费5分钟草稿进行分析,不难发现第一第二象限绘制基本是一致的,三四象限绘制是一致的。所以我们需要根据象限来不同的计算绘制文字线。大家花5分钟分析一下三四象限。

需要明白,产品效果是可以跟随手势任意旋转的,每一个扇形在用户手上可能被旋转个百八十圈。那每个扇形都可能被旋转到任意角度吧?但是需要清楚,再牛逼还是在360度的可视化范围内。而正余弦函数大家应该知道是周期性函数,360度为一个周期,后面循环往复。所以任意旋转角度都能够被换算到360度以内,也就可以根据角度计算出所在象限。

js 复制代码
//进行象限获取
determineQuadrant(angleDegrees: number): number {
  // 将角度标准化到0-360度之间
  let standardizedAngle = angleDegrees % 360;

  // 将负角度转换为对应的正角度
  if (standardizedAngle < 0) {
    standardizedAngle += 360;
  }

  // 判断角度所在的象限
  if (standardizedAngle >= 0 && standardizedAngle < 90) {
    return 1;
  } else if (standardizedAngle >= 90 && standardizedAngle < 180) {
    return 2;
  } else if (standardizedAngle >= 180 && standardizedAngle < 270) {
    return 3;
  } else {
    return 4;
  }
}

所以不难绘制出所有的指示线吧,文字绘制加上看看效果,如果难就画图勾股定理安排。

js 复制代码
private drawTextLine(radius: number, halfAngle: number, angleData: CirclePieChartMaxBean) {
  const pointToCircle = 20
  const titleLineToPDistance = 20
  const mTitleMarginStar = 10
  this.context.font = "44px"
  let centerX = (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
  let centerY = -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
  if (this.determineQuadrant(halfAngle + 90.0) == 1 || this.determineQuadrant(halfAngle + 90.0) == 2) {
    let centerX =
      (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
    let centerY =
      -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
    let lineTwoPointX =
      (radius + pointToCircle + titleLineToPDistance) * Math.sin(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let lineTwoPointY =
      -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let textTitleMeasure = this.context.measureText(angleData.title)
    let textSubMeasure = this.context.measureText(angleData.sub)

    this.context.beginPath()
    this.context.moveTo(centerX, centerY)
    this.context.lineTo(lineTwoPointX, lineTwoPointY)
    this.context.lineTo(
      lineTwoPointX + textTitleMeasure.width + mTitleMarginStar,
      lineTwoPointY
    )

    let textDrawStartX = lineTwoPointX + mTitleMarginStar
    let textSubStartX = lineTwoPointX + textTitleMeasure.width + mTitleMarginStar - textSubMeasure.width

    this.context.lineWidth = 1
    this.context.shadowBlur = 4
    this.context.shadowOffsetY = 0.5
    this.context.shadowColor = 'rgb(0,0,0)'
    this.context.strokeStyle = 'rgba(53,158,255,1.00)'
    this.context.stroke()

    //绘制线头圆点
    this.context.beginPath()
    this.context.arc(centerX, centerY, 5, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fill()

    this.context.beginPath()
    this.context.arc(centerX, centerY, 3, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(255,255,255,1.00)'
    this.context.fill()

    //绘制文字
    this.context.shadowBlur = 2
    this.context.shadowOffsetY = 0.5
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fillText(angleData.sub, textSubStartX, lineTwoPointY - textSubMeasure.height / 2)
    this.context.fillStyle = 'rgb(0,0,0)'
    this.context.fillText(angleData.title, textDrawStartX, lineTwoPointY + textTitleMeasure.height)


  } else if (this.determineQuadrant(halfAngle + 90.0) == 3 || this.determineQuadrant(halfAngle + 90.0) == 4) {

    let centerX =
      (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
    let centerY =
      -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))


    let lineTwoPointX =
      (radius + pointToCircle + titleLineToPDistance) * Math.sin(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let lineTwoPointY =
      -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let textTitleMeasure = this.context.measureText(angleData.title)
    let textSubMeasure = this.context.measureText(angleData.sub)

    this.context.beginPath()

    this.context.moveTo(lineTwoPointX - textTitleMeasure.width - mTitleMarginStar, lineTwoPointY)
    this.context.lineTo(lineTwoPointX, lineTwoPointY)
    this.context.lineTo(
      centerX, centerY
    )
    this.context.stroke()
    //绘制文字
    let textDrawStartX = lineTwoPointX - textTitleMeasure.width - mTitleMarginStar
    let textSubStartX = textDrawStartX

    this.context.lineWidth = 1
    this.context.shadowBlur = 4
    this.context.shadowOffsetY = 0.5
    this.context.shadowColor = 'rgb(0,0,0)'
    this.context.strokeStyle = 'rgba(53,158,255,1.00)'
    this.context.stroke()

    //绘制线头圆点
    this.context.beginPath()
    this.context.arc(centerX, centerY, 5, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fill()

    this.context.beginPath()
    this.context.arc(centerX, centerY, 3, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(255,255,255,1.00)'
    this.context.fill()

    //绘制文字
    this.context.shadowBlur = 2
    this.context.shadowOffsetY = 0.5
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fillText(angleData.sub, textSubStartX, lineTwoPointY - textSubMeasure.height / 2)
    this.context.fillStyle = 'rgb(0,0,0)'
    this.context.fillText(angleData.title, textDrawStartX, lineTwoPointY + textTitleMeasure.height)
  }
}

效果如下:

4、增加手势

好用的统计图表必须不仅仅是要好看,还要好的交互。而扇形统计图更适合增加手势旋转。 官方手势基础API地址

手势API能拿到用户旋转的角度,在此案例中只需要将当前旋转的角度交给context.rotate即可。

手势使用也很简单:下图所示,rotation就是用户手势旋转角度。

手势角度作为绘制弧度的其实角度,所以在进行旋转之前角度需要加上手势角度rotation。我们最后进行封装为draw()方法,在手势每次触发旋转回调函数中进行调用重新绘制。

js 复制代码
@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  @State rotation: number = 0
  @State rotateValue: number = 0

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
          // 双指旋转触发该手势事件
        .gesture(
          RotationGesture()
            .onActionStart((event: GestureEvent) => {
              console.info('Rotation start')
            })
            .onActionUpdate((event: GestureEvent) => {
              this.rotation = this.rotateValue + event.angle
              this.draw()
            })
            .onActionEnd(() => {
              this.rotateValue = this.rotation
              console.info('Rotation end')
            })
        )
        .onReady(() => {
          this.draw()
        })
    }
    .height('100%')
  }

  private draw() {
    let radius = 80
    //记录上一个绘制结束的角度。
    let routeAngle = 0
    let defaultAngle = -90 + this.rotation

    let width = this.context.width
    let height = this.context.height
    this.context.translate(width / 2, height / 2)

    let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0)
    for (let index = 0; index < dataList.length; index++) {
      const angleData = dataList[index]
      //每个扇形区域
      let sweepAngle = angleData.valueData / sum * 360
      let halfAngle = defaultAngle + sweepAngle / 2
      console.log("sweepAngle", sweepAngle.toString())

      //绘制扇形区域开始
      this.drawArc(routeAngle, sweepAngle, radius, angleData)
      //开始绘制指示线
      this.drawTextLine(radius, halfAngle, angleData)

      defaultAngle += sweepAngle
      routeAngle += sweepAngle
    }
  }
}

效果如上,运行之后,手势触发旋转,会在右下角重新绘制。其实并非在右下角,应该会在对角直线上一直往前绘制,在js和arkTs中绘制需要明白,每次调用绘制函数,是需要清空画布重新绘制,避免保留上一次的绘制干扰当前绘制。

js 复制代码
//清空画布,避免重绘,否则每次drawSector都会绘制一次。最后重叠交错
this.context.clearRect(0, 0, width, height)

并在内部绘制一个白色圆圈挡住中间部分,形成圆环。

代码:

js 复制代码
private draw() {

  this.context.save()
  //设置字体大小
  this.context.font = "30px"
  let radius = 80
  let routeAngle = 0
  let startAngle = 0
  let defaultAngle = -90 + this.rotation
  let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
  let width = this.context.width
  let height = this.context.height
  //清空画布,避免重绘,否则每次drawSector都会绘制一次。最后重叠交错
  this.context.clearRect(0, 0, width, height)
  this.context.save()
  //将坐标圆心变换到屏幕中心即圆心
  this.context.translate(width / 2, height / 2)
  for (let index = 0; index < dataList.length; index++) {
    const angleData = dataList[index]
    //每个扇形区域
    let sweepAngle = angleData.valueData / sum * 360
    let halfAngle = defaultAngle + sweepAngle / 2
    console.log("sweepAngle", sweepAngle.toString())

    //绘制扇形区域开始
    this.drawArc(routeAngle, sweepAngle, radius, angleData)
    //开始绘制指示线
    this.drawTextLine(radius, halfAngle, angleData)

    defaultAngle += sweepAngle
    routeAngle += sweepAngle
  }
  this.drawTextCenter(radius)
  this.context.restore()
}

//绘制中心白圆和文字。
private drawTextCenter(radius: number) {
  this.context.arc(0, 0, radius - 25, 0, Math.PI * 2)
  this.context.fillStyle = Color.White
  this.context.shadowBlur = 0
  this.context.shadowOffsetY = 0
  this.context.fill()

  this.context.fillStyle = 'rgba(53,158,255,1.00)'
  this.context.shadowBlur = 6
  this.context.shadowOffsetY = 3
  this.context.shadowColor = 'rgba(53,158,255,1.00)'
  let centerTextMeasure = this.context.measureText("APP")
  this.context.font = "40px"
  this.context.fillText("APP", -centerTextMeasure.width / 2, centerTextMeasure.height / 2)
}

至于文字分段显示、展示手势按下部分🈯️示线等功能也不难,大家可以自行实现,由于时间问题,这篇文章到此结束。有疑问或者错误可以评论区指出。

五、总结

看完官方语言基础和状态装饰器文档,试了这篇自定义内容,还是能够直接上手的。自定义对于UI的排面来说是极其重要的,如下是最近要出小册案例的冰山一角,小册内容极其丰富,里面涵盖了View,Compose,Js以及ArkTs大家可以关注点赞,希望对大家所有帮助。

相关推荐
合作小小程序员小小店32 分钟前
web网页开发,在线%考试管理%系统,基于Idea,vscode,html,css,vue,java,maven,springboot,mysql
java·前端·系统架构·vue·intellij-idea·springboot
非专业程序员Ping1 小时前
Vibe Coding 实战!花了两天时间,让 AI 写了一个富文本渲染引擎!
ios·ai·swift·claude·vibecoding
天天进步20151 小时前
CSS Grid与Flexbox:2025年响应式布局终极指南
前端·css
Boop_wu2 小时前
[Java EE] 计算机基础
java·服务器·前端
江上清风山间明月2 小时前
Android 系统超级实用的分析调试命令
android·内存·调试·dumpsys
Novlan12 小时前
TDesign UniApp 组件库来了
前端
用户47949283569152 小时前
React DevTools 组件名乱码?揭秘从开发到生产的代码变形记
前端·react.js
百锦再3 小时前
第12章 测试编写
android·java·开发语言·python·rust·go·erlang
顾安r3 小时前
11.8 脚本网页 打砖块max
服务器·前端·html·css3
倚栏听风雨3 小时前
typescript 方法前面加* 是什么意思
前端