大家好啊!我是说的鸿蒙,今天来点大家想看的东西。
前言
最近自己的项目里有个需求,因为鸿蒙官方暂时没有对应的控件所以只能自己实现,最后使用自定义的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复活博主,爱你们捏💗💗。