系列文章《鸿蒙开发从入门到专家》第 7 篇(中级篇)
前言
网络层封装好了,现在能拉数据、能展示列表。但如果每个页面都手写一套按钮样式、弹窗逻辑、加载动画,代码会迅速膨胀成一锅粥。企业级应用的核心竞争力之一,就是有一套设计统一、行为一致的组件库。
ArkUI 提供了强大的自定义组件能力:@Builder、@BuilderParam、@Styles、@Extend,加上属性动画和转场动画,足以支撑一套完整的组件体系。本文从实战出发,搭建一个包含按钮、卡片、弹窗、加载骨架屏的微型组件库。
一、自定义组件的四种武器
ArkUI 里有四个容易混淆的概念,先厘清它们的区别:
1.1 @Component:完整的独立组件
// components/LoadingButton.ets
@Component
export struct LoadingButton {
@Prop label: string = '确认'
@Prop loading: boolean = false
@Prop disabled: boolean = false
@Prop type: 'primary' | 'secondary' | 'danger' = 'primary'
private onClickHandler?: () => void
private getBackgroundColor(): ResourceColor {
if (this.disabled || this.loading) return '#CCCCCC'
switch (this.type) {
case 'primary': return '#317AF7'
case 'secondary': return '#F0F0F0'
case 'danger': return '#E84026'
default: return '#317AF7'
}
}
private getTextColor(): ResourceColor {
if (this.disabled || this.loading) return '#999999'
return this.type === 'secondary' ? '#333333' : Color.White
}
build() {
Button() {
Row({ space: 8 }) {
if (this.loading) {
LoadingProgress()
.width(18)
.height(18)
.color(Color.White)
}
Text(this.label)
.fontSize(16)
.fontColor(this.getTextColor())
}
}
.width('100%')
.height(48)
.backgroundColor(this.getBackgroundColor())
.enabled(!this.loading && !this.disabled)
.onClick(() => {
if (this.onClickHandler) {
this.onClickHandler()
}
})
}
}
// 使用
LoadingButton({
label: '提交订单',
loading: this.isSubmitting,
disabled: this.formInvalid,
type: 'primary',
onClickHandler: () => this.handleSubmit()
})
1.2 @Builder:轻量级 UI 片段
@Builder 适合不需要独立状态的 UI 复用场景,比如列表项、卡片模板:
// builders/CardBuilders.ets
@Builder
export function InfoCard(title: string, content: string, icon?: Resource) {
Column({ space: 8 }) {
Row({ space: 8 }) {
if (icon) {
Image(icon).width(20).height(20)
}
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
}
.width('100%')
Text(content)
.fontSize(14)
.fontColor('#666666')
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12)
.width('100%')
}
// 使用
InfoCard('订单状态', '您的订单已发货,预计明天送达', $r('app.media.icon_truck'))
1.3 @BuilderParam:组件插槽
Vue 和 React 开发者熟悉的"插槽"概念,在 ArkUI 里用 @BuilderParam 实现:
// components/Dialog.ets
@Component
export struct CustomDialog {
@Prop title: string = '提示'
@Prop visible: boolean = false
@BuilderParam contentBuilder?: () => void
@BuilderParam footerBuilder?: () => void
private onClose?: () => void
build() {
if (this.visible) {
Stack({ alignContent: Alignment.Center }) {
// 遮罩层
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(0, 0, 0, 0.5)')
.onClick(() => {
if (this.onClose) this.onClose()
})
// 弹窗内容
Column({ space: 16 }) {
Text(this.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.width('100%')
if (this.contentBuilder) {
this.contentBuilder()
}
if (this.footerBuilder) {
this.footerBuilder()
} else {
// 默认底部按钮
Row({ space: 12 }) {
Button('取消')
.width(120)
.height(40)
.backgroundColor('#F0F0F0')
.fontColor('#666666')
.onClick(() => {
if (this.onClose) this.onClose()
})
Button('确认')
.width(120)
.height(40)
.backgroundColor('#317AF7')
.onClick(() => {
if (this.onClose) this.onClose()
})
}
}
}
.padding(24)
.backgroundColor(Color.White)
.borderRadius(16)
.width('80%')
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
}
}
// 使用:自定义内容插槽
CustomDialog({
title: '确认删除',
visible: this.showDeleteDialog,
contentBuilder: () => {
Text('删除后无法恢复,是否继续?')
.fontSize(14)
.fontColor('#999999')
},
footerBuilder: () => {
Row({ space: 12 }) {
Button('再想想')
.width(120)
.height(40)
.backgroundColor('#F0F0F0')
.onClick(() => this.showDeleteDialog = false)
Button('果断删除')
.width(120)
.height(40)
.backgroundColor('#E84026')
.onClick(() => this.confirmDelete())
}
},
onClose: () => this.showDeleteDialog = false
})
1.4 @Styles 与 @Extend:样式复用
// 全局样式函数
@Styles
function cardStyle() {
.padding(16)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: 4 })
}
// 扩展 Text 组件
@Extend(Text)
function secondaryText() {
.fontSize(14)
.fontColor('#999999')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
// 使用
Column() {
Text('标题').fontSize(18).fontWeight(FontWeight.Bold)
Text('这是一段描述文本').secondaryText()
}
.cardStyle()
.width('100%')
二、实战:骨架屏组件
骨架屏是提升用户体验的神器,在数据加载时展示一个"灰色占位块"的预览:
// components/Skeleton.ets
@Component
export struct Skeleton {
@Prop width: string | number = '100%'
@Prop height: string | number = 16
@Prop borderRadius: number = 4
@State shimmerOffset: number = -100
aboutToAppear() {
// 启动 shimmer 动画
animateTo({
duration: 1500,
iterations: -1, // 无限循环
curve: Curve.Linear
}, () => {
this.shimmerOffset = 200
})
}
build() {
Stack() {
// 底色
Column()
.width(this.width)
.height(this.height)
.backgroundColor('#E8E8E8')
.borderRadius(this.borderRadius)
// shimmer 高光
Column()
.width(60)
.height(this.height)
.backgroundColor('linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0) 100%)')
.position({ x: this.shimmerOffset })
}
.clip(true)
.borderRadius(this.borderRadius)
}
}
// 骨架屏列表组件
@Component
export struct SkeletonList {
@Prop itemCount: number = 5
@Builder
SkeletonItem() {
Row({ space: 12 }) {
Skeleton({ width: 80, height: 80, borderRadius: 8 })
Column({ space: 8 }) {
Skeleton({ width: '60%', height: 16 })
Skeleton({ width: '80%', height: 14 })
Skeleton({ width: '40%', height: 12 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
}
build() {
Column({ space: 8 }) {
ForEach(Array.from({ length: this.itemCount }), () => {
this.SkeletonItem()
})
}
.width('100%')
.backgroundColor(Color.White)
}
}
三、动画系统:属性动画与转场动画
3.1 属性动画
属性动画是最常用的动画类型,改变组件的某个属性值并自动插值:
@Entry
@Component
struct AnimationDemo {
@State scaleValue: number = 1
@State opacityValue: number = 1
@State rotateValue: number = 0
build() {
Column({ space: 30 }) {
Text('动画目标')
.width(120)
.height(120)
.backgroundColor('#317AF7')
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.scale({ x: this.scaleValue, y: this.scaleValue })
.opacity(this.opacityValue)
.rotate({ angle: this.rotateValue })
.animation({
duration: 500,
curve: Curve.EaseInOut,
iterations: 1
})
Row({ space: 16 }) {
Button('缩放')
.onClick(() => {
this.scaleValue = this.scaleValue === 1 ? 1.5 : 1
})
Button('淡入淡出')
.onClick(() => {
this.opacityValue = this.opacityValue === 1 ? 0.3 : 1
})
Button('旋转')
.onClick(() => {
this.rotateValue += 90
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
.animation() 方法定义动画参数,当绑定的状态变量变化时,ArkUI 自动执行过渡动画。
3.2 animateTo:显式控制动画
animateTo 适合同时改变多个属性,或者需要精确控制动画时机的场景:
Button('展开详情')
.onClick(() => {
animateTo({
duration: 300,
curve: Curve.EaseOut,
onFinish: () => {
console.info('动画结束')
}
}, () => {
this.isExpanded = !this.isExpanded
this.containerHeight = this.isExpanded ? 300 : 100
this.arrowRotation = this.isExpanded ? 180 : 0
})
})
3.3 转场动画:页面与组件的过渡
组件出现/消失时的过渡效果:
if (this.showPanel) {
Column() {
Text('弹出的面板')
}
.width('100%')
.height(300)
.backgroundColor(Color.White)
.transition(TransitionEffect.asymmetric(
// 入场:从底部滑入 + 淡入
TransitionEffect.move(TransitionEdge.BOTTOM)
.combine(TransitionEffect.opacity(0)),
// 出场:向底部滑出 + 淡出
TransitionEffect.move(TransitionEdge.BOTTOM)
.combine(TransitionEffect.opacity(0))
))
}
3.4 列表项滑动删除
ListItem() {
// 列表项内容
Row() {
Text('左滑删除我')
}
.width('100%')
.height(60)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
}
.swipeAction({
end: {
builder: () => {
Row() {
Text('删除')
.fontColor(Color.White)
.onClick(() => {
this.deleteItem(item.id)
})
}
.width(80)
.height('100%')
.backgroundColor('#E84026')
.justifyContent(FlexAlign.Center)
}
}
})
四、组件库目录结构建议
components/
├── base/ # 基础组件
│ ├── LoadingButton.ets
│ ├── Skeleton.ets
│ └── EmptyState.ets # 空状态页
├── form/ # 表单组件
│ ├── FormInput.ets
│ ├── FormPicker.ets
│ └── FormValidator.ets
├── feedback/ # 反馈组件
│ ├── CustomDialog.ets
│ ├── Toast.ets
│ └── ActionSheet.ets
├── display/ # 展示组件
│ ├── ImageViewer.ets
│ ├── Badge.ets
│ └── Tag.ets
└── builders/
├── CardBuilders.ets
└── ListBuilders.ets
小结与下篇预告
本文搭建了一套可复用的组件体系:
@Component 做完整组件,@Builder 做 UI 片段,@BuilderParam 做插槽
骨架屏用属性动画实现 shimmer 效果,提升加载体验
属性动画用
.animation()或animateTo,转场用TransitionEffect列表滑动操作用
swipeAction实现删除等交互
下一篇(第 8 篇)进入高级内容:性能优化。我们会从启动速度、列表渲染和内存占用三个维度,给出可落地的优化方案和测量方法。