HarmonyOS 自定义RenderNode 绘图实战

大家好啊!我是说的鸿蒙,今天来点大家想看的东西。

前言

最近自己的项目里有个需求,因为鸿蒙官方暂时没有对应的控件所以只能自己实现,最后使用自定义的RenderNode搭配NodeController + NodeContainer完成效果。在这个过程中解锁的很多新奇的体验想要分享给好兄弟们,那么话不多说,快端上来罢!

本文内容包括 NodeContainer、NodeController、RenderNode等知识点,如不熟悉需要通过官方文档了解内容:

实际应用成果展示

实现过程

为了方便展示以及聚焦本文核心内容,这里单独创建一个Demo来展示效果,带着大家一步一步学习,想看源码的同学可以去结尾查看代码仓库。

准备

首先我们的Demo完成的内容为: 绘制一个自定义的Progress控件 ,并且显示当前进度数值 以及总数值 ,进度条控件下方有颜色列表 ,点击可以更改进度条颜色

首先我们需要继承NodeController

JavaScript 复制代码
import { FrameNode, NodeController, UIContext } from '@kit.ArkUI';
export class CustomNodeController extends NodeController {
  makeNode(uiContext: UIContext): FrameNode | null {
    return null
  }
}

Index

JavaScript 复制代码
import { CustomNodeController } from '../CustomNodeController'
import { ArcData } from '../ArcData'
@Entry
@Component
struct Index {
  //自定义NodeController
  controller: CustomNodeController = new CustomNodeController()
  //颜色数据类,一个为十六进制色值一个为ARGB色值
  items: ArcData[] = []
  @State index: number = 0
  //当前进度
  private pro: number = 0

  aboutToAppear(): void {
    //初始化色块区域
    this.items.push(new ArcData(0xFFDC82, {
      alpha: 255,
      red: 255,
      green: 220,
      blue: 130
    }))
    this.items.push(new ArcData(0x4682C8, {
      alpha: 255,
      red: 70,
      green: 130,
      blue: 200
    }))

    this.items.push(new ArcData(0x78966E, {
      alpha: 255,
      red: 120,
      green: 150,
      blue: 110
    }))
  }

  build() {
    Column({ space: 20 }) {
      
      //Node占位区域
      Stack() {
        NodeContainer(this.controller)
          .width(150)
          .height(150)
      }
      .width(300)
      .height(200)
      .borderRadius(20)
      .backgroundColor(Color.White)
      .shadow(ShadowStyle.OUTER_DEFAULT_SM)

      //颜色列表区域
      List({ space: 20 }) {
        ForEach(this.items, (item: ArcData, index: number) => {
          ListItem() {
            Stack()
              .width(this.index == index ? 55 : 50)
              .height(this.index == index ? 55 : 50)
              .backgroundColor(item.rectColor)
              .borderRadius(10)
              .animation({ duration: 250 })
          }
          .onClick(() => {
            //更新index
            this.index = index
          })
        })
      }
      .listDirection(Axis.Horizontal)
      .height(60)
      .width('auto')

      Button('Add')
        .onClick(() => {
          //更新进度
          this.pro = this.pro + 1
        })
    }
    .justifyContent(FlexAlign.Center)
    .height('100%')
    .width('100%')
    .backgroundColor('#fffcfcfc')
  }
}

效果:

RenderNode

自定义渲染节点 (RenderNode)是更加轻量的渲染节点,仅具备与渲染相关的功能。它提供了设置基础渲染属性的能力,以及节点的动态添加、删除和自定义绘制的能力。这里我们使用继承RenderNode实现自定义节点

JavaScript 复制代码
export class CustomRender extends RenderNode {

}

自定义属性

接下来我们要考虑到控件必要以及可能需要用到的属性并一一添加:

JavaScript 复制代码
export class CustomRender extends RenderNode {
  //进度条背景颜色
  progressBgColor: common2D.Color = {
    alpha: 255,
    red: 234,
    green: 239,
    blue: 244
  }
  //进度条颜色
  progressColor: common2D.Color = {
    alpha: 255,
    red: 255,
    green: 255,
    blue: 255
  }
  //总数颜色
  totalColor: common2D.Color = {
    alpha: 255,
    red: 180,
    green: 184,
    blue: 191
  }
  //当前进度值颜色
  countColor: common2D.Color = {
    alpha: 255,
    red: 255,
    green: 255,
    blue: 255
  }
  //当前进度
  progress: number = 0
  //进度条宽度
  progressWidth: number = 30
  //总数
  totalCount: number = 0
}

绘制图形

通过重写RenderNode中的draw方法,可以自定义RenderNode的绘制内容,通过invalidate接口可以主动触发节点的重新绘制。 以绘制圆弧为核心,整体的绘制逻辑如下:

  • 使用drawArc绘制圆弧作为进度背景 以及进度
  • 使用drawTextBlob绘制进度值 以及总数值

在绘制前我们还需要添加属性,来标定进度条画刷绘制的角度

JavaScript 复制代码
private swipeAngle: number = 0

然后在draw方法中绘制进度条背景进度条两个数值

JavaScript 复制代码
draw(context: DrawContext): void {
  //通过上下文获取Canvas
  const canvas: drawing.Canvas = context.canvas
  //公共区域
  const commonRect: common2D.Rect = {
    //为进度条宽度让出空间
    left: this.progressWidth / 2,
    top: this.progressWidth / 2,
    right: canvas.getWidth() - this.progressWidth / 2,
    bottom: canvas.getHeight() - this.progressWidth / 2
  }
  //进度条背景笔刷
  const arcBgPen: drawing.Pen = new drawing.Pen()
  arcBgPen.setStrokeWidth(this.progressWidth)
  arcBgPen.setAntiAlias(true)
  arcBgPen.setDither(true)
  arcBgPen.setCapStyle(drawing.CapStyle.ROUND_CAP)
  arcBgPen.setColor(this.progressBgColor)
  arcBgPen.setBlendMode(drawing.BlendMode.MULTIPLY)
  canvas.attachPen(arcBgPen)
  canvas.drawArc(commonRect, 135, 270)
  //使用过后需要去除掉画布中的画笔或画刷
  canvas.detachPen()

  //进度条笔刷
  const arcPen: drawing.Pen = new drawing.Pen()
  arcPen.setStrokeWidth(this.progressWidth)
  arcPen.setAntiAlias(true)
  arcPen.setDither(true)
  arcPen.setCapStyle(drawing.CapStyle.ROUND_CAP)
  arcPen.setColor(this.progressColor)
  canvas.attachPen(arcPen)
  canvas.drawArc(commonRect, 135, this.swipeAngle)
  canvas.detachPen()

  //进度值画刷
  const brush = new drawing.Brush();
  brush.setColor(this.countColor);
  //进度值字体属性
  const font = new drawing.Font();
  font.setSize(100)
  font.enableSubpixel(true)
  font.enableEmbolden(true)
  font.setEdging(drawing.FontEdging.SUBPIXEL_ANTI_ALIAS)
  const textBlob =
    drawing.TextBlob.makeFromString(this.progress.toString(), font, drawing.TextEncoding.TEXT_ENCODING_UTF8);
  canvas.attachBrush(brush)
  //让视觉重心偏上
  canvas.drawTextBlob(textBlob, (canvas.getWidth() / 2) - ((textBlob.bounds().right - textBlob.bounds().left) / 2),
    canvas.getHeight() / 2 + (textBlob.bounds().bottom - textBlob.bounds().top) / 3);
  canvas.detachBrush();

  //总数值画刷
  const total = new drawing.Brush();
  total.setColor(this.totalColor);
  total.setBlendMode(drawing.BlendMode.MULTIPLY)
  //总数值字体属性
  const totalFont = new drawing.Font();
  totalFont.setSize(60)
  totalFont.enableSubpixel(true)
  totalFont.enableEmbolden(true)
  totalFont.setEdging(drawing.FontEdging.SUBPIXEL_ANTI_ALIAS)
  const totalText =
    drawing.TextBlob.makeFromString(this.totalCount.toString(), totalFont, drawing.TextEncoding.TEXT_ENCODING_UTF8);
  canvas.attachBrush(total)
  canvas.drawTextBlob(totalText, (canvas.getWidth() / 2) - ((totalText.bounds().right - totalText.bounds().left) / 2),
    canvas.getHeight() - 30);
  canvas.detachBrush();
}

动画更新

在我们完成绘制 步骤之后其实就离成功很近了,但是美中不足的是,如果每次更新都是以动画的方式会在体验时流畅不少,所以在这里我们需要用到Animator来实现动画效果。 Animator需要使用Animator.create配合AnimatorOptions定义动画选项 为入参构造出AnimatorResult,使用AnimatorResult控制动画播放、结束、取消、监听等功能。

在动画播放过程中是区间的方式来呈现的,所以我们还需要一个值来记录上次绘制过后的角度

JavaScript 复制代码
private lastAngle: number = 0

动画更新方法:

JavaScript 复制代码
updateProgress(pro: number) {
  //更新当前数值
  this.progress = pro
  //计算角度
  const progress = Math.min(Math.max(pro / this.totalCount, 0), 1);
  const sweepAngle = 270 * progress; //最终度数
  let anim: AnimatorResult = Animator.create({
    duration: 500,
    easing: "fast-out-slow-in",
    delay: 150,
    fill: "forwards",
    direction: "normal",
    iterations: 1,
    begin: this.lastAngle, //上次的角度
    end: sweepAngle //最终角度
  })
  anim.onFrame = (pro) => {
    //更新进度
    this.swipeAngle = pro
    this.invalidate()
  }
  anim.onFinish = () => {
    anim.cancel()
  }
  anim.play()
  //更新上次角度
  this.lastAngle = sweepAngle
}

其他

除此之外在添加几条更新其他属性并重绘的方法暴露给外部调用:

JavaScript 复制代码
updateProgressBgColor(color: common2D.Color) {
  this.progressBgColor = color
  this.invalidate()
}

updateProgressColor(color: common2D.Color) {
  this.progressColor = color
  this.invalidate()
}

updateCountColor(color: common2D.Color) {
  this.countColor = color
  this.invalidate()
}

updateTotalColor(color: common2D.Color) {
  this.totalColor = color
  this.invalidate()
}

NodeController

NodeController用于实现自定义节点的创建、显示、更新等操作的管理,并负责将自定义节点挂载到NodeContainer上。

在我们的自定义Controller中我们需要负责的是管理自定义RenderNode(包括初始化确定大小 以及向外部提供更新属性的方法 )。 首先我们需要实现makeNode方法

JavaScript 复制代码
export class CustomNodeController extends NodeController {
  //根节点
  private rootNode?: FrameNode = undefined
  //自定义RenderNode
  private render: CustomRender = new CustomRender()

  makeNode(uiContext: UIContext): FrameNode | null {
    if (!this.rootNode) {
      this.rootNode = new FrameNode(uiContext)
    }
    return this.rootNode
  }
}

尺寸初始化以及挂载

我们需要将确定自定义RenderNode尺寸大小的职责交给NodeController来做,所以我们需要考虑到其初始化方法中调用的优先级

JavaScript 复制代码
//当NodeController绑定的NodeContainer挂载显示后触发此回调。
aboutToAppear(): void {
   console.log('CustomNodeController', 'aboutToAppear')
}

//当NodeController绑定的NodeContainer布局的时候触发此回调。
aboutToResize(size: Size): void {
  console.log('CustomNodeController', 'aboutToResize')
  this.render.frame = {
    x: 0,
    y: 0,
    width: size.width,
    height: size.height
  }
  this.rootNode?.getRenderNode()?.appendChild(this.render)
}

//当实例绑定的NodeContainer创建的时候进行回调。
makeNode(uiContext: UIContext): FrameNode | null {
  console.log('CustomNodeController', 'makeNode')
  if (!this.rootNode) {
    this.rootNode = new FrameNode(uiContext)
  }
  return this.rootNode
}

Log:

css 复制代码
com.examp...progress  I     CustomNodeController makeNode
com.examp...progress  I     CustomNodeController aboutToAppear
com.examp...progress  I     CustomNodeController aboutToResize

所以,我们需要在aboutToResize中获取组件大小,并且将RenderNode大小初始化并将Node加入到根Node中。

JavaScript 复制代码
aboutToResize(size: Size): void {
  console.log('CustomNodeController', 'aboutToResize')
  this.render.frame = {
    x: 0,
    y: 0,
    width: size.width,
    height: size.height
  }
  this.rootNode?.getRenderNode()?.appendChild(this.render)
}

更新RenderNode方法

这里我们使用链式调用的方式,将RenderNode属性初始化暴露给外层:

JavaScript 复制代码
updateColor(color: common2D.Color) {
  this.render.updateProgressColor(color)
}

updateProgress(progress: number) {
  this.render.updateProgress(progress)
}

updateCountColor(color: common2D.Color) {
  this.render.updateCountColor(color)
}

updateTotalColor(color: common2D.Color) {
  this.render.updateTotalColor(color)
}

progressWidth(width: number) {
  this.render.progressWidth = width
  return this
}

progressColor(color: common2D.Color) {
  this.render.progressColor = color
  return this
}

progressBgColor(color: common2D.Color) {
  this.render.progressBgColor = color
  return this
}

totalColor(color: common2D.Color) {
  this.render.totalColor = color
  return this
}

total(total: number) {
  this.render.totalCount = total
  return this
}

countColor(color: common2D.Color) {
  this.render.countColor = color
  return this
}

progress(pro: number) {
  this.render.progress = pro
  return this
}

如此,我们将外部补全:

Index

JavaScript 复制代码
import { CustomNodeController } from '../CustomNodeController'
import { ArcData } from '../ArcData'


@Entry
@Component
struct Index {
  //自定义NodeController
  controller: CustomNodeController = new CustomNodeController()
  //颜色数据类,一个为十六进制色值一个为ARGB色值
  items: ArcData[] = []
  @State index: number = 0
  //当前进度
  private pro: number = 0

  aboutToAppear(): void {
    //初始化色块区域
    this.items.push(new ArcData(0xFFDC82, {
      alpha: 255,
      red: 255,
      green: 220,
      blue: 130
    }))
    this.items.push(new ArcData(0x4682C8, {
      alpha: 255,
      red: 70,
      green: 130,
      blue: 200
    }))

    this.items.push(new ArcData(0x78966E, {
      alpha: 255,
      red: 120,
      green: 150,
      blue: 110
    }))
    //初始化控件属性
    this.controller.progressWidth(40)
      .progressColor(this.items[0].arcColor)
      .countColor(this.items[0].arcColor)
      .totalColor(this.items[0].arcColor)
      .total(3)
  }

  build() {
    Column({ space: 20 }) {
      //Node占位区域
      Stack() {
        NodeContainer(this.controller)
          .width(150)
          .height(150)
      }
      .width(300)
      .height(200)
      .borderRadius(20)
      .backgroundColor(Color.White)
      .shadow(ShadowStyle.OUTER_DEFAULT_SM)

      //颜色列表区域
      List({ space: 20 }) {
        ForEach(this.items, (item: ArcData, index: number) => {
          ListItem() {
            Stack()
              .width(this.index == index ? 55 : 50)
              .height(this.index == index ? 55 : 50)
              .backgroundColor(item.rectColor)
              .borderRadius(10)
              .animation({ duration: 250 })
          }
          .onClick(() => {
            //更新index以及进度颜色
            this.index = index
            this.controller.updateColor(item.arcColor)
            this.controller.updateCountColor(item.arcColor)
            this.controller.updateTotalColor(item.arcColor)
          })
        })
      }
      .listDirection(Axis.Horizontal)
      .height(60)
      .width('auto')

      Button('Add')
        .onClick(() => {
          //更新进度
          this.pro = this.pro + 1
          this.controller.updateProgress(this.pro)
        })
    }
    .justifyContent(FlexAlign.Center)
    .height('100%')
    .width('100%')
    .backgroundColor('#fffcfcfc')
  }
}

Demo效果

结尾

那么好了,本篇内容到这里就结束了,其实实现起来并不算难,重点需要理解如何通过canvas以及drawing提供的绘制方法将你想要的图形绘制出来同时配合上Animator让效果看起来更加自然即可,任何意见都可以在评论区展现出来罢,下期再见,评论区扣1复活博主,爱你们捏💗💗。

相关推荐
_一条咸鱼_3 小时前
深度揭秘!Android HorizontalScrollView 使用原理全解析
android·面试·android jetpack
_一条咸鱼_3 小时前
揭秘 Android RippleDrawable:深入解析使用原理
android·面试·android jetpack
_一条咸鱼_3 小时前
深入剖析:Android Snackbar 使用原理的源码级探秘
android·面试·android jetpack
_一条咸鱼_3 小时前
揭秘 Android FloatingActionButton:从入门到源码深度剖析
android·面试·android jetpack
_一条咸鱼_3 小时前
深度剖析 Android SmartRefreshLayout:原理、源码与实战
android·面试·android jetpack
_一条咸鱼_3 小时前
揭秘 Android GestureDetector:深入剖析使用原理
android·面试·android jetpack
_一条咸鱼_3 小时前
深入探秘 Android DrawerLayout:源码级使用原理剖析
android·面试·android jetpack
_一条咸鱼_3 小时前
深度揭秘:Android CardView 使用原理的源码级剖析
android·面试·android jetpack
_一条咸鱼_3 小时前
惊爆!Android RecyclerView 性能优化全解析
android·面试·android jetpack
_一条咸鱼_3 小时前
探秘 Android RecyclerView 惯性滑动:从源码剖析到实践原理
android·面试·android jetpack