[Harmony] 鸿蒙自定义组件

Posted by xiuyuantech on 2025-09-06

在ArkUI中,UI显示的内容均为组件,由框架直接提供的称为系统组件,由开发者定义的称为自定义组件。

在进行 UI 界面开发时,通常不是简单的将系统组件进行组合使用,而是需要考虑代码可复用性、业务逻辑与UI分离,后续版本演进等因素。

因此,将UI和部分业务逻辑封装成自定义组件是不可或缺的能力。

自定义组件具有以下特点:

可组合:允许开发者组合使用系统组件、及其属性和方法。

可重用:自定义组件可以被其他组件重用,并作为不同的实例在不同的父组件或容器中使用。

数据驱动UI更新:通过状态变量的改变,来驱动UI的刷新。

模块化:让代码结构更清晰,便于团队协作。

实现方式

  • 系统组件组合

    当系统提供的标准组件(如Column、Row、Flex、Button、Text、Image等)能够满足我们的需求时,就需要使用系统组件组合自定义组件。

  • 自定义控件

    当系统提供的标准组件(如Button、Text、Image、Radio、Checkbox等)无法满足我们的需求时,就需要使用自定义组件。

  • 自定义布局

    当系统提供的标准布局(如Column、Row、Flex等)无法满足我们的需求时,就需要使用自定义布局。

    这就像是建筑师需要设计特殊形状的房间时,不能只用标准的长方形,而需要自己测量和安排每个部件的位置。

基本结构

1、 struct
struct:自定义组件基于struct实现,struct + 自定义组件名 + {…}的组合构成自定义组件,不能有继承关系。对于struct的实例化,可以省略new。

注意:自定义组件名、类名、函数名不能和系统组件名相同。

2、 @Component
@Component装饰器仅能装饰struct关键字声明的数据结构。struct被@Component装饰后具备组件化的能力,需要实现build方法描述UI,一个struct只能被一个@Component装饰。

3、 build()函数
build()函数用于定义自定义组件的声明式UI描述,自定义组件必须定义build()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entry装饰的自定义组件,其build()函数下的根节点唯一且必要,且必须为容器组件,其中ForEach禁止作为根节点。

@Component装饰的自定义组件,其build()函数下的根节点唯一且必要,可以为非容器组件,其中ForEach禁止作为根节点。

在build函数中:

不允许声明本地变量

不允许在UI描述里直接使用console.info,但允许在方法或者函数里使用

不允许创建本地的作用域

不允许调用没有用@Builder装饰的方法,允许系统组件的参数是TS方法的返回值

不允许switch语法,如果需要使用条件判断,请使用if

不允许使用表达式

不允许直接改变状态变量

系统组件组合

下面是一个用户卡片组件的例子,它接收用户名、头像和在线状态等信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
 @Component
struct UserCard {
@Prop userName: string
@Prop avatarUrl: string
@Prop isOnline: boolean = false

build() {
Row() {
Image(this.avatarUrl)
.width(50)
.height(50)
.borderRadius(25)

Column() {
Text(this.userName)
.fontSize(16)
.fontWeight(FontWeight.Bold)

Text(this.isOnline ? '在线' : '离线')
.fontSize(12)
.fontColor(this.isOnline ? '#10B981' : '#6B7280')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
}

自定义控件

当系统提供的标准布局组件(如Column、Row、Flex、Button、Text、Image、Radio、Checkbox等)无法满足我们的需求时,就需要使用Canvas自定义组件。
视频动画如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import { ArrayList } from '@kit.ArkTS'
import { Rect } from '@ohos.UiTest'

@Preview
@ComponentV2
export struct AirVelocity {
@Param @Once minLevel: number = 0
@Param @Once maxLevel: number = 6
@Param @Once currentLevel: number = 3
@Param normalColor: string | number = '#f5f5f5'
@Param selectedColor: string | number = '#005eff'
@Param backGroundColor: string | number = Color.White
@Local private itemHeightRate: number = 0.150
@Local private itemHeight: number = 22
@Local private itemWidth: number = 0
@Local private blankHeightRate: number = 0.019
@Local private blankHeight: number = 3
@Param paddingAll: number = 5
@Param radiusAll: number = 5
@Local private settings: RenderingContextSettings = new RenderingContextSettings(true)
@Local private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
@Local private path: Path2D = new Path2D()
@Local private edgeRects: ArrayList<Rect> = new ArrayList()
@Event onChange: (oldValue: number, newValue: number) => void = (oldValue, newValue) => {

}

aboutToAppear(): void {
if (this.minLevel < 0) {
this.minLevel = 0
}
if (this.maxLevel < this.minLevel) {
this.maxLevel = this.minLevel
this.currentLevel = this.maxLevel
}
}

build() {
Column() {
Canvas(this.canvasContext)
.width('100%')
.height('100%')
.onReady(() => {
this.drawRects()
})
.gesture(GestureGroup(GestureMode.Exclusive,
TapGesture()
.onAction((event: GestureEvent) => {
if (event.fingerList?.length) {
console.debug('AirVelocity TapGesture', JSON.stringify(event))
let x = event.fingerList[0].localX
let y = event.fingerList[0].localY
this.drawRoundRectByTouch(x, y)
}
}),
PanGesture()
.onActionUpdate((event: GestureEvent) => {
if (event.fingerList?.length) {
console.debug('AirVelocity PanGesture onActionUpdate', JSON.stringify(event))
let x = event.fingerList[0].localX
let y = event.fingerList[0].localY
this.drawRoundRectByTouch(x, y)
}
})
.onActionEnd((event: GestureEvent) => {
console.debug('AirVelocity PanGesture onActionEnd', JSON.stringify(event))
})
,
))
}
.width('100%')
.height('100%')
.padding(this.paddingAll)
.borderRadius(this.radiusAll)
.backgroundColor(this.backGroundColor)
}

drawRoundRectByTouch(touchX: number, touchY: number) {
if (touchY > this.canvasContext.height) {
this.onRedrawRects(this.maxLevel)
return
}
let rect: Rect
for (let i = 0; i < this.edgeRects.length; i++) {
rect = this.edgeRects[i]
if (rect && touchX >= rect.left && touchX <= rect.right
&& touchY >= rect.top && touchY <= rect.bottom) {
this.onRedrawRects(i)
break
}
}
}

private onRedrawRects(destIndex: number) {
let old = this.currentLevel
this.currentLevel = this.maxLevel - destIndex
this.drawRects()
this.onChange(old, this.currentLevel)
}

private drawRects() {
this.itemWidth = this.canvasContext.width
let height = this.canvasContext.height * 6 / this.maxLevel
this.itemHeight = height * this.itemHeightRate
this.blankHeight = height * this.blankHeightRate
this.edgeRects.clear()
this.canvasContext.reset()
let itemX: number = 0, itemY: number
for (let index = 0; index < this.maxLevel; index++) {
itemY = (this.itemHeight + this.blankHeight) * index
this.drawRoundRect(index, itemX, itemY, this.itemWidth, this.itemHeight)
}
}

private drawRoundRect(index: number, x: number, y: number, width: number, height: number) {
if (this.maxLevel - index > this.currentLevel) {
this.canvasContext.strokeStyle = this.normalColor
this.canvasContext.fillStyle = this.normalColor
} else {
this.canvasContext.strokeStyle = this.selectedColor
this.canvasContext.fillStyle = this.selectedColor
}
this.path = new Path2D()
//topLeft
this.path.moveTo(x + this.radiusAll, y)
this.path.quadraticCurveTo(x, y, x, y + this.radiusAll)

//bottomLeft
let bottomLeftStartY = y + height - this.radiusAll
this.path.lineTo(x, bottomLeftStartY)
this.path.quadraticCurveTo(x, bottomLeftStartY + this.radiusAll, x + this.radiusAll, y + height)

//bottomRight
let bottomRightStartX = x + width - this.radiusAll
this.path.lineTo(bottomRightStartX, y + height)
this.path.quadraticCurveTo(x + width, y + height, x + width, y + height - this.radiusAll)


//topRight
this.path.lineTo(x + width, y + this.radiusAll)
this.path.quadraticCurveTo(x + width, y, x + width - this.radiusAll, y)
this.path.lineTo(x + this.radiusAll, y)

this.path.closePath()
this.canvasContext.fill(this.path)

let rect: Rect = {
left: x,
top: y,
right: x + width,
bottom: y + height
}
if (!this.edgeRects.has(rect)) {
this.edgeRects.add(rect)
}
}
}

自定义布局

自定义布局的两个核心方法:

  • onMeasureSize:测量阶段,确定组件和子组件的尺寸

  • onPlaceChildren:布局阶段,确定每个子组件的具体位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
  @Component
struct FlowLayout {
@Builder
doNothingBuilder() {}

@BuilderParam builder: () => void = this.doNothingBuilder
@State itemSpacing: number = 8
@State lineSpacing: number = 8

onMeasureSize(selfLayoutInfo: GeometryInfo, children: Array<Measurable>, constraint: ConstraintSizeOptions) {
let maxWidth = constraint.maxWidth as number
let currentLineWidth = 0
let totalHeight = 0
let lineHeight = 0

children.forEach((child) => {
let childResult: MeasureResult = child.measure({
minHeight: 0,
minWidth: 0,
maxWidth: maxWidth,
maxHeight: constraint.maxHeight
})

if (currentLineWidth + childResult.width > maxWidth) {
// 换行
totalHeight += lineHeight + this.lineSpacing
currentLineWidth = childResult.width + this.itemSpacing
lineHeight = childResult.height
} else {
currentLineWidth += childResult.width + this.itemSpacing
lineHeight = Math.max(lineHeight, childResult.height)
}
})

totalHeight += lineHeight

return {
width: maxWidth,
height: totalHeight
}
}

onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Array<Layoutable>, constraint: ConstraintSizeOptions) {
let maxWidth = constraint.maxWidth as number
let currentX = 0
let currentY = 0
let lineHeight = 0

children.forEach((child) => {
if (currentX + child.measureResult.width > maxWidth) {
// 换行
currentX = 0
currentY += lineHeight + this.lineSpacing
lineHeight = 0
}

child.layout({ x: currentX, y: currentY })
currentX += child.measureResult.width + this.itemSpacing
lineHeight = Math.max(lineHeight, child.measureResult.height)
})
}

build() {
this.builder()
}
}

页面和自定义组件的生命周期

自定义组件:@Component装饰的UI单元,可以组合多个系统组件实现UI的复用,可以调用组件的生命周期。

页面:即应用的UI页面。可以由一个或者多个自定义组件组成,@Entry装饰的自定义组件为页面的入口组件,即页面的根节点,一个页面有且仅能有一个@Entry。只有被@Entry装饰的组件才可以调用页面的生命周期。

页面生命周期,即被@Entry装饰的组件生命周期,提供以下生命周期接口:

  • onPageShow:页面每次显示时触发一次,包括路由过程、应用进入前台等场景。

  • onPageHide:页面每次隐藏时触发一次,包括路由过程、应用进入后台等场景。

  • onBackPress:当用户点击返回按钮时触发。

组件生命周期,即一般用@Component装饰的自定义组件的生命周期,提供以下生命周期接口:

  • aboutToAppear:组件即将出现时回调该接口,具体时机为在创建自定义组件的新实例后,在执行其build()函数之前执行。

  • aboutToDisappear:aboutToDisappear函数在自定义组件析构销毁之前执行。不允许在aboutToDisappear函数中改变状态变量,特别是@Link变量的修改可能会导致应用程序行为不稳定。

屏幕适配

在多设备时代,我们的应用需要在手机、平板、电脑等不同尺寸的屏幕上都能良好显示。响应式设计就像是一件能够自动调整大小的衣服,让我们的组件能够适应各种屏幕尺寸。

鸿蒙系统的响应式设计断点规范:

  • sm(小屏):0-600vp,主要是手机竖屏
  • md(中屏):600-840vp,主要是手机横屏、小平板
  • lg(大屏):840vp以上,主要是大平板、电脑