5 第4章 高级UI开发
HarmonyOS应用开发者高级认证教程
6 第4章 高级UI开发
6.1 学习目标
本章将深入讲解HarmonyOS ArkUI框架的高级UI开发技术,涵盖自定义组件的深层机制、Canvas自定义绘制、高级动画系统、手势与交互、自定义布局以及声明式语法增强等核心知识。通过本章学习,读者将能够:
- 深入理解自定义组件生命周期与性能优化策略
- 掌握Canvas绑定模式与自定义绘制技术
- 实现复杂动画效果,包括转场动画、弹簧物理动画与共享元素转场
- 灵活运用手势系统实现复杂交互逻辑
- 实现自定义布局接口,完成流式布局与瀑布流布局
- 掌握LazyForEach等声明式语法的高级用法
6.2 4.1 自定义组件深入
6.2.1 4.1.1 自定义组件的生命周期
在HarmonyOS ArkUI中,自定义组件(@Component)拥有一套完整的生命周期回调机制。理解这些回调的触发时机和执行顺序,是编写高性能UI的基础。
自定义组件的生命周期主要包括以下阶段:
aboutToAppear():组件即将创建,在build()方法之前执行。适合进行数据初始化、网络请求等操作。aboutToDisappear():组件即将销毁。适合进行资源释放、定时器清理、事件解绑等操作。onDidBuild():组件build()方法执行完毕后回调。
@Component
struct LifecycleDemo {
@State message: string = '加载中...'
private timerId: number = -1
// 组件即将出现,在build()之前执行
aboutToAppear(): void {
console.info('[Lifecycle] aboutToAppear 被调用')
// 适合进行数据初始化
this.fetchData()
// 启动定时器
this.timerId = setInterval(() => {
console.info('定时器执行中...')
}, 1000)
}
// 组件即将销毁
aboutToDisappear(): void {
console.info('[Lifecycle] aboutToDisappear 被调用')
// 必须清理定时器,防止内存泄漏
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
}
// build()执行完毕后的回调
onDidBuild?(): void {
console.info('[Lifecycle] onDidBuild 被调用')
}
async fetchData(): Promise<void> {
// 模拟异步数据获取
this.message = '数据加载完成'
}
build() {
Column() {
Text(this.message)
.fontSize(20)
}
}
}生命周期执行顺序的关键细节:
aboutToAppear()在组件首次创建时调用一次,组件因状态变化重新渲染时不会再次调用。- 如果父组件通过条件渲染(
if)移除子组件后又重新创建,子组件的aboutToAppear()会再次执行。 aboutToDisappear()在组件从组件树中永久移除时调用。
6.2.2 4.1.2 组件参数传递与验证
ArkUI提供了多种装饰器用于组件间参数传递,高级开发中需要深入理解每种装饰器的行为差异。
装饰器对比:
| 装饰器 | 数据流向 | 是否可修改 | 同步机制 |
|---|---|---|---|
@Prop |
父→子 | 子组件可修改副本 | 单向同步 |
@Link |
父↔︎子 | 子组件可修改引用 | 双向同步 |
@Provide/@Consume |
祖先→后代 | 可修改 | 跨层级双向同步 |
@Observed/@ObjectLink |
父→子 | 嵌套对象可观察 | 深层属性监听 |
// 使用 @Observed 标记嵌套对象类
@Observed
class UserProfile {
name: string
age: number
address: AddressInfo
constructor(name: string, age: number, address: AddressInfo) {
this.name = name
this.age = age
this.address = address
}
}
@Observed
class AddressInfo {
city: string
district: string
constructor(city: string, district: string) {
this.city = city
this.district = district
}
}
// 父组件
@Component
struct ParentComponent {
@State profile: UserProfile = new UserProfile('张三', 28,
new AddressInfo('深圳', '南山区'))
build() {
Column() {
Text(`父组件: ${this.profile.name}`)
// 使用 @ObjectLink 传递嵌套对象
ChildComponent({ profile: this.profile })
Button('修改城市')
.onClick(() => {
// 修改嵌套属性,子组件会自动更新
this.profile.address.city = '北京'
})
}
}
}
// 子组件
@Component
struct ChildComponent {
// @ObjectLink 接收 @Observed 标记的对象
@ObjectLink profile: UserProfile
build() {
Column() {
Text(`子组件: ${this.profile.name}`)
Text(`城市: ${this.profile.address.city}`)
.fontSize(16)
.fontColor(Color.Blue)
}
}
}组件参数验证模式:
在高级开发中,建议在 aboutToAppear() 中对传入参数进行验证:
@Component
struct ValidatedInput {
@Prop inputValue: string = ''
@State isValid: boolean = true
@State errorMessage: string = ''
aboutToAppear(): void {
this.validate(this.inputValue)
}
private validate(value: string): void {
if (value.length < 3) {
this.isValid = false
this.errorMessage = '输入长度不能少于3个字符'
} else if (value.length > 50) {
this.isValid = false
this.errorMessage = '输入长度不能超过50个字符'
} else {
this.isValid = true
this.errorMessage = ''
}
}
build() {
Column() {
Text(this.inputValue)
.fontColor(this.isValid ? Color.Black : Color.Red)
if (!this.isValid) {
Text(this.errorMessage)
.fontSize(12)
.fontColor(Color.Red)
}
}
}
}6.2.3 4.1.3 组件复用与抽象
良好的组件抽象是构建大型应用的关键。以下是组件复用的核心策略:
策略一:泛型组件模式
// 定义通用列表项接口
interface ListItemData {
id: string
title: string
}
// 通用列表容器组件
@Component
struct GenericList<T extends ListItemData> {
items: T[] = []
@BuilderParam itemBuilder: (item: T) => void = this.defaultBuilder
@Builder
defaultBuilder(item: T) {
Text(item.title)
}
build() {
List() {
ForEach(this.items, (item: T) => {
ListItem() {
this.itemBuilder(item)
}
}, (item: T) => item.id)
}
}
}策略二:@BuilderParam 与 @Builder 封装
@Component
struct CardContainer {
@BuilderParam header: () => void = this.defaultHeader
@BuilderParam content: () => void = this.defaultContent
@BuilderParam footer: () => void = this.emptyBuilder
shadow: boolean = true
@Builder
defaultHeader() {
Text('默认标题').fontSize(18).fontWeight(FontWeight.Bold)
}
@Builder
defaultContent() {
Text('默认内容').fontSize(14)
}
@Builder
emptyBuilder() {}
build() {
Column() {
// 头部区域
Row() {
this.header()
}
.width('100%')
.padding(12)
Divider()
// 内容区域
Column() {
this.content()
}
.width('100%')
.padding(12)
// 底部区域(可选)
if (this.footer !== this.emptyBuilder) {
Divider()
Row() {
this.footer()
}
.width('100%')
.padding(12)
}
}
.backgroundColor(Color.White)
.borderRadius(12)
.shadow(this.shadow ? {
radius: 8,
color: 'rgba(0,0,0,0.15)',
offsetX: 0,
offsetY: 2
} : { radius: 0, color: Color.Transparent, offsetX: 0, offsetY: 0 })
}
}
// 使用示例
@Entry
@Component
struct CardUsageExample {
build() {
Column() {
CardContainer({
shadow: true,
header: () => {
this.customHeader()
},
content: () => {
this.customContent()
},
footer: () => {
this.customFooter()
}
})
}
.padding(16)
.backgroundColor('#F1F3F5')
}
@Builder
customHeader() {
Row() {
Image($r('app.media.app_icon'))
.width(40)
.height(40)
.borderRadius(20)
Column() {
Text('用户卡片').fontSize(16).fontWeight(FontWeight.Bold)
Text('在线').fontSize(12).fontColor(Color.Green)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
}
@Builder
customContent() {
Text('这是卡片的自定义内容区域,可以放置任意UI元素。')
.fontSize(14)
}
@Builder
customFooter() {
Row({ space: 12 }) {
Button('点赞').type(ButtonType.Capsule).height(32)
Button('评论').type(ButtonType.Capsule).height(32)
Button('分享').type(ButtonType.Capsule).height(32)
}
.justifyContent(FlexAlign.End)
.width('100%')
}
}6.2.4 4.1.4 自定义组件的性能优化
优化策略一:使用 @State 精确控制刷新范围
// 反模式:整个组件因单个状态变化而重建
@Component
struct BadPractice {
@State title: string = ''
@State count: number = 0
@State list: string[] = []
build() {
Column() {
Text(this.title) // 只需要 title
Text(`${this.count}`) // 只需要 count
ForEach(this.list, (item: string) => {
Text(item) // 只需要 list
})
}
}
}
// 优化方案:拆分组件,缩小刷新范围
@Component
struct TitleView {
@Prop title: string = ''
build() {
Text(this.title)
}
}
@Component
struct CounterView {
@Prop count: number = 0
build() {
Text(`${this.count}`)
}
}
@Component
struct ListView {
@Prop list: string[] = []
build() {
ForEach(this.list, (item: string) => {
Text(item)
}, (item: string) => item)
}
}
@Component
struct GoodPractice {
@State title: string = ''
@State count: number = 0
@State list: string[] = []
build() {
Column() {
TitleView({ title: this.title }) // 只有 title 变化时刷新
CounterView({ count: this.count }) // 只有 count 变化时刷新
ListView({ list: this.list }) // 只有 list 变化时刷新
}
}
}优化策略二:使用 @Watch 避免不必要的计算
@Component
struct WatchOptimization {
@State searchText: string = ''
@State filteredList: string[] = []
allItems: string[] = []
// 使用 @Watch 精确监听变化
@Watch('onSearchChange')
@State debouncedSearch: string = ''
onSearchChange(): void {
// 只在搜索词真正变化时执行过滤
this.filterItems(this.debouncedSearch)
}
private filterItems(keyword: string): void {
if (keyword.length === 0) {
this.filteredList = [...this.allItems]
} else {
this.filteredList = this.allItems.filter(
(item: string) => item.includes(keyword)
)
}
}
build() {
Column() {
TextInput({ text: this.searchText, placeholder: '搜索...' })
.onChange((value: string) => {
this.searchText = value
// 防抖处理
this.debouncedSearch = value
})
List() {
ForEach(this.filteredList, (item: string) => {
ListItem() {
Text(item)
}
}, (item: string) => item)
}
}
}
}6.3 4.2 自定义绘制
6.3.1 4.2.1 Canvas组件与绑定模式
ArkUI提供了两种Canvas使用模式:声明式Canvas 和 绑定式Canvas。绑定模式通过 CanvasRenderingContext2D 提供了更灵活的绘制能力。
// 绑定模式Canvas示例
@Component
struct BoundCanvasDemo {
// 创建离屏渲染上下文
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings)
@State canvasWidth: number = 300
@State canvasHeight: number = 400
build() {
Column() {
Canvas(this.context)
.width(this.canvasWidth)
.height(this.canvasHeight)
.backgroundColor('#F5F5F5')
.onReady(() => {
this.drawContent()
})
}
}
private drawContent(): void {
const ctx = this.context
// 清空画布
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 绘制矩形
ctx.fillStyle = '#4FC3F7'
ctx.fillRect(20, 20, 120, 80)
// 绘制描边矩形
ctx.strokeStyle = '#E91E63'
ctx.lineWidth = 3
ctx.strokeRect(160, 20, 120, 80)
// 绘制圆形
ctx.beginPath()
ctx.arc(80, 200, 50, 0, Math.PI * 2)
ctx.fillStyle = '#66BB6A'
ctx.fill()
// 绘制文本
ctx.font = '20px sans-serif'
ctx.fillStyle = '#333333'
ctx.fillText('Canvas绑定模式', 60, 300)
}
}6.3.2 4.2.2 图形绘制API
路径绘制与贝塞尔曲线:
@Component
struct PathDrawingDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings)
build() {
Canvas(this.ctx)
.width(360)
.height(500)
.onReady(() => {
this.drawPaths()
})
}
private drawPaths(): void {
const ctx = this.ctx
// 1. 绘制直线段路径 - 折线图
ctx.beginPath()
ctx.moveTo(20, 100)
ctx.lineTo(80, 40)
ctx.lineTo(140, 80)
ctx.lineTo(200, 30)
ctx.lineTo(260, 70)
ctx.lineTo(320, 20)
ctx.strokeStyle = '#2196F3'
ctx.lineWidth = 2
ctx.stroke()
// 2. 绘制二次贝塞尔曲线
ctx.beginPath()
ctx.moveTo(20, 200)
ctx.quadraticCurveTo(180, 120, 340, 200)
ctx.strokeStyle = '#FF5722'
ctx.lineWidth = 3
ctx.stroke()
// 3. 绘制三次贝塞尔曲线
ctx.beginPath()
ctx.moveTo(20, 300)
ctx.bezierCurveTo(100, 220, 260, 380, 340, 300)
ctx.strokeStyle = '#9C27B0'
ctx.lineWidth = 3
ctx.stroke()
// 4. 绘制心形路径
this.drawHeart(ctx, 180, 420, 60)
}
private drawHeart(ctx: CanvasRenderingContext2D,
cx: number, cy: number, size: number): void {
ctx.beginPath()
ctx.moveTo(cx, cy + size / 4)
// 左半部分
ctx.bezierCurveTo(
cx, cy - size / 4,
cx - size / 2, cy - size / 4,
cx - size / 2, cy + size / 8
)
ctx.bezierCurveTo(
cx - size / 2, cy + size / 3,
cx, cy + size / 2,
cx, cy + size * 0.6
)
// 右半部分
ctx.bezierCurveTo(
cx, cy + size / 2,
cx + size / 2, cy + size / 3,
cx + size / 2, cy + size / 8
)
ctx.bezierCurveTo(
cx + size / 2, cy - size / 4,
cx, cy - size / 4,
cx, cy + size / 4
)
ctx.fillStyle = '#E91E63'
ctx.fill()
}
}变换与渐变:
@Component
struct TransformGradientDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings)
build() {
Canvas(this.ctx)
.width(360)
.height(500)
.onReady(() => {
this.drawTransforms()
})
}
private drawTransforms(): void {
const ctx = this.ctx
// 1. 线性渐变
const linearGradient = ctx.createLinearGradient(20, 20, 320, 20)
linearGradient.addColorStop(0, '#FF6B6B')
linearGradient.addColorStop(0.5, '#4ECDC4')
linearGradient.addColorStop(1, '#45B7D1')
ctx.fillStyle = linearGradient
ctx.fillRect(20, 20, 300, 60)
// 2. 径向渐变
const radialGradient = ctx.createRadialGradient(180, 200, 10, 180, 200, 80)
radialGradient.addColorStop(0, '#FFD93D')
radialGradient.addColorStop(0.6, '#FF6B6B')
radialGradient.addColorStop(1, '#6C5CE7')
ctx.beginPath()
ctx.arc(180, 200, 80, 0, Math.PI * 2)
ctx.fillStyle = radialGradient
ctx.fill()
// 3. 旋转变换
ctx.save()
ctx.translate(100, 380)
ctx.rotate(Math.PI / 6) // 旋转30度
ctx.fillStyle = '#00B894'
ctx.fillRect(-40, -20, 80, 40)
ctx.restore()
// 4. 缩放变换
ctx.save()
ctx.translate(260, 380)
ctx.scale(1.5, 0.8)
ctx.fillStyle = '#6C5CE7'
ctx.fillRect(-30, -25, 60, 50)
ctx.restore()
}
}6.3.3 4.2.3 离屏渲染与缓存策略
对于复杂图形,可以使用离屏Canvas进行预渲染,避免每帧重复计算:
@Component
struct OffscreenCacheDemo {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private ctx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.settings)
// 离屏Canvas用于缓存
private offscreenSettings: RenderingContextSettings =
new RenderingContextSettings(true)
private offscreenCtx: CanvasRenderingContext2D =
new CanvasRenderingContext2D(this.offscreenSettings)
@State isCacheReady: boolean = false
@State rotationAngle: number = 0
build() {
Column() {
Canvas(this.ctx)
.width(360)
.height(360)
.onReady(() => {
this.buildCache()
})
Slider({
value: this.rotationAngle,
min: 0,
max: 360,
step: 1
})
.onChange((value: number) => {
this.rotationAngle = value
this.renderFromCache()
})
}
}
// 构建离屏缓存
private buildCache(): void {
// 在离屏上下文绘制复杂图形
this.offscreenCtx.fillStyle = '#FF6B6B'
this.offscreenCtx.beginPath()
this.offscreenCtx.arc(100, 100, 80, 0, Math.PI * 2)
this.offscreenCtx.fill()
// 添加复杂细节
for (let i = 0; i < 12; i++) {
const angle = (Math.PI * 2 / 12) * i
const x = 100 + Math.cos(angle) * 60
const y = 100 + Math.sin(angle) * 60
this.offscreenCtx.beginPath()
this.offscreenCtx.arc(x, y, 10, 0, Math.PI * 2)
this.offscreenCtx.fillStyle = '#4ECDC4'
this.offscreenCtx.fill()
}
this.isCacheReady = true
this.renderFromCache()
}
// 从缓存渲染到主Canvas
private renderFromCache(): void {
this.ctx.clearRect(0, 0, 360, 360)
this.ctx.save()
this.ctx.translate(180, 180)
this.ctx.rotate(this.rotationAngle * Math.PI / 180)
this.ctx.translate(-100, -100)
// 直接绘制缓存的图像数据
this.ctx.drawImage(this.offscreenCtx, 0, 0)
this.ctx.restore()
}
}6.4 4.3 高级动画系统
6.4.1 4.3.1 属性动画与显式动画
ArkUI提供了两种基本动画方式:属性动画(隐式动画)和显式动画。
@Component
struct AnimationDemo {
@State boxWidth: number = 100
@State boxColor: string = '#4FC3F7'
@State boxRadius: number = 8
@State opacity: number = 1.0
build() {
Column({ space: 20 }) {
// 属性动画:状态变化自动触发过渡
Row() {
Text('属性动画')
.fontSize(14)
.fontColor(Color.White)
}
.width(this.boxWidth)
.height(60)
.backgroundColor(this.boxColor)
.borderRadius(this.boxRadius)
.opacity(this.opacity)
// 使用 .animation() 修饰属性变化
.animation({
duration: 500,
curve: Curve.EaseInOut,
delay: 0,
fill: 'forwards',
iterations: 1,
playMode: PlayMode.Normal
})
// 显式动画:使用 animateTo
Button('显式动画 - 展开')
.onClick(() => {
animateTo({
duration: 800,
curve: Curve.FastOutSlowIn,
onFinish: () => {
console.info('动画执行完毕')
}
}, () => {
this.boxWidth = 300
this.boxColor = '#66BB6A'
this.boxRadius = 30
})
})
Button('显式动画 - 收缩')
.onClick(() => {
animateTo({
duration: 600,
curve: Curve.FastOutSlowIn
}, () => {
this.boxWidth = 100
this.boxColor = '#4FC3F7'
this.boxRadius = 8
})
})
Button('透明度动画')
.onClick(() => {
animateTo({
duration: 1000,
curve: Curve.EaseInOut,
iterations: 2,
playMode: PlayMode.AlternateReverse
}, () => {
this.opacity = this.opacity === 1.0 ? 0.1 : 1.0
})
})
}
.width('100%')
.padding(20)
.alignItems(HorizontalAlign.Center)
}
}6.4.2 4.3.2 转场动画
页面转场与组件转场:
// 自定义页面转场
@Entry
@Component
struct PageTransitionDemo {
@State isActive: boolean = false
build() {
Column({ space: 20 }) {
Text('页面转场演示')
.fontSize(24)
.fontWeight(FontWeight.Bold)
// 组件转场 - 使用 .transition()
if (this.isActive) {
Column() {
Text('转场组件')
.fontSize(20)
.fontColor(Color.White)
}
.width(200)
.height(200)
.backgroundColor('#FF6B6B')
.borderRadius(16)
.justifyContent(FlexAlign.Center)
// 自定义插入转场
.transition(TransitionEffect.OPACITY.combine(
TransitionEffect.translate({ x: 200 }).animation({
duration: 600,
curve: Curve.EaseOut
})
).combine(
TransitionEffect.scale({ x: 0.5, y: 0.5 }).animation({
duration: 600,
curve: Curve.EaseOut
})
))
}
Button(this.isActive ? '移除组件' : '添加组件')
.onClick(() => {
this.isActive = !this.isActive
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}页面级别的转场配置:
@Entry
@Component
struct PageLevelTransition {
@State navigateNext: boolean = false
build() {
NavDestination() {
Column() {
Text('详情页')
.fontSize(24)
}
}
// 页面进入转场
.onPageShow(() => {
console.info('页面显示')
})
// 页面退出转场
.onPageHide(() => {
console.info('页面隐藏')
})
.transition(TransitionEffect.OPACITY.combine(
TransitionEffect.translate({ y: 100 })
))
}
}6.4.3 4.3.3 弹簧物理动画
弹簧动画通过物理模型实现更自然的动画效果:
@Component
struct SpringAnimationDemo {
@State ballX: number = 50
@State ballScale: number = 1.0
build() {
Column({ space: 30 }) {
// 弹簧动画小球
Circle()
.width(60)
.height(60)
.fill('#FF6B6B')
.translate({ x: this.ballX })
.scale({ x: this.ballScale, y: this.ballScale })
// 弹簧参数说明:
// response: 弹簧响应时间(毫秒),值越大动画越慢
// dampingFraction: 阻尼系数(0-1),值越小弹跳越多
// overlap: 是否允许过冲
Button('弹簧动画 - 弹跳')
.onClick(() => {
animateTo({
duration: 1000,
curve: {
curve: CurveStatus.Spring,
// 自定义弹簧参数
} as SpringCurve,
iterations: 1
}, () => {
this.ballX = this.ballX === 50 ? 250 : 50
})
})
Button('缩放弹簧')
.onClick(() => {
animateTo({
duration: 800,
curve: Curve.Friction
}, () => {
this.ballScale = this.ballScale === 1.0 ? 1.8 : 1.0
})
})
// 使用 geometryTransition 实现几何变换
Row() {
Circle()
.width(this.ballScale > 1.2 ? 100 : 60)
.height(this.ballScale > 1.2 ? 100 : 60)
.fill('#4ECDC4')
.geometryTransition('sharedCircle')
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(20)
}
}6.4.4 4.3.4 帧动画实现
@Component
struct FrameAnimationDemo {
@State currentFrame: number = 0
@State isPlaying: boolean = false
private frameCount: number = 24
private timerId: number = -1
private fps: number = 24
// 帧资源列表
private frameResources: Resource[] = []
aboutToAppear(): void {
// 加载帧资源
for (let i = 1; i <= this.frameCount; i++) {
this.frameResources.push($r(`app.media.frame_${i.toString().padStart(3, '0')}`))
}
}
aboutToDisappear(): void {
this.stopAnimation()
}
private startAnimation(): void {
if (this.isPlaying) return
this.isPlaying = true
const interval = 1000 / this.fps
this.timerId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frameCount
}, interval)
}
private stopAnimation(): void {
if (this.timerId !== -1) {
clearInterval(this.timerId)
this.timerId = -1
}
this.isPlaying = false
}
build() {
Column({ space: 20 }) {
// 显示当前帧
Image(this.frameResources[this.currentFrame])
.width(200)
.height(200)
.objectFit(ImageFit.Contain)
// 帧进度指示
Text(`帧: ${this.currentFrame + 1} / ${this.frameCount}`)
.fontSize(14)
Row({ space: 20 }) {
Button(this.isPlaying ? '暂停' : '播放')
.onClick(() => {
if (this.isPlaying) {
this.stopAnimation()
} else {
this.startAnimation()
}
})
Button('重置')
.onClick(() => {
this.stopAnimation()
this.currentFrame = 0
})
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
}6.5 4.4 手势与交互
6.5.1 4.4.1 手势系统详解
ArkUI提供了丰富的内置手势识别器:
| 手势类型 | 说明 | 触发条件 |
|---|---|---|
TapGesture |
点击手势 | 按下并抬起 |
LongPressGesture |
长按手势 | 按住超过500ms |
PanGesture |
拖拽手势 | 按住并移动 |
PinchGesture |
捏合手势 | 双指缩放 |
RotationGesture |
旋转手势 | 双指旋转 |
SwipeGesture |
滑动手势 | 快速滑动 |
@Component
struct GestureSystemDemo {
@State offsetX: number = 0
@State offsetY: number = 0
@State scale: number = 1.0
@State rotation: number = 0
@State gestureText: string = '等待手势...'
@State lastScale: number = 1.0
@State lastRotation: number = 0
build() {
Column() {
Text(this.gestureText)
.fontSize(14)
.margin({ bottom: 20 })
// 多手势组合示例
Rect()
.width(200)
.height(200)
.fill('#4FC3F7')
.borderRadius(16)
.translate({ x: this.offsetX, y: this.offsetY })
.scale({ x: this.scale, y: this.scale })
.rotate({ angle: this.rotation })
// 拖拽手势
.gesture(
PanGesture({ fingers: 1, distance: 5 })
.onActionStart(() => {
this.gestureText = '拖拽开始'
})
.onActionUpdate((event: GestureEvent) => {
this.offsetX = event.offsetX
this.offsetY = event.offsetY
this.gestureText =
`拖拽中: (${event.offsetX.toFixed(0)}, ${event.offsetY.toFixed(0)})`
})
.onActionEnd(() => {
this.gestureText = '拖拽结束'
})
)
// 捏合手势(通过 parallelGesture 并行识别)
.parallelGesture(
PinchGesture({ fingers: 2 })
.onActionStart(() => {
this.lastScale = this.scale
this.gestureText = '捏合开始'
})
.onActionUpdate((event: GestureEvent) => {
this.scale = this.lastScale * event.scale
this.gestureText = `缩放: ${this.scale.toFixed(2)}`
})
)
// 旋转手势
.parallelGesture(
RotationGesture({ fingers: 2 })
.onActionStart(() => {
this.lastRotation = this.rotation
this.gestureText = '旋转开始'
})
.onActionUpdate((event: GestureEvent) => {
this.rotation = this.lastRotation + event.angle
this.gestureText = `旋转: ${this.rotation.toFixed(1)}°`
})
)
// 重置按钮
Button('重置变换')
.margin({ top: 30 })
.onClick(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.offsetX = 0
this.offsetY = 0
this.scale = 1.0
this.rotation = 0
})
this.gestureText = '已重置'
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}6.5.2 4.4.2 手势组合与竞争
ArkUI支持三种手势组合方式:
GestureGroup(GestureMode.Sequence):顺序识别,按顺序完成所有手势GestureGroup(GestureMode.Parallel):并行识别,同时识别多个手势GestureGroup(GestureMode.Exclusive):互斥识别,只识别第一个匹配的手势
@Component
struct GestureCombinationDemo {
@State statusText: string = '等待操作...'
@State tapCount: number = 0
build() {
Column({ space: 20 }) {
Text(this.statusText)
.fontSize(16)
// 示例1:互斥手势 - 单击 vs 双击
Column() {
Text('互斥手势:单击/双击')
.fontSize(14)
.fontColor(Color.Gray)
Text('点击此区域')
.width(300)
.height(80)
.textAlign(TextAlign.Center)
.backgroundColor('#E3F2FD')
.borderRadius(12)
.gestureGroup(GestureMode.Exclusive,
// 双击优先
TapGesture({ count: 2 })
.onAction(() => {
this.statusText = '检测到双击!'
}),
// 单击
TapGesture({ count: 1 })
.onAction(() => {
this.tapCount++
this.statusText = `单击次数: ${this.tapCount}`
})
)
}
// 示例2:顺序手势 - 长按后拖拽
Column() {
Text('顺序手势:长按 → 拖拽')
.fontSize(14)
.fontColor(Color.Gray)
Text('长按后拖拽')
.width(300)
.height(80)
.textAlign(TextAlign.Center)
.backgroundColor('#FFF3E0')
.borderRadius(12)
.gesture(
GestureGroup(GestureMode.Sequence,
LongPressGesture({ duration: 500 })
.onAction(() => {
this.statusText = '长按已识别,请拖拽...'
}),
PanGesture({ distance: 10 })
.onActionUpdate((event: GestureEvent) => {
this.statusText =
`拖拽中: (${event.offsetX.toFixed(0)}, ${event.offsetY.toFixed(0)})`
})
)
)
}
// 示例3:并行手势
Column() {
Text('并行手势:拖拽 + 缩放')
.fontSize(14)
.fontColor(Color.Gray)
Text('单指拖拽 / 双指缩放')
.width(300)
.height(120)
.textAlign(TextAlign.Center)
.backgroundColor('#E8F5E9')
.borderRadius(12)
.gesture(
GestureGroup(GestureMode.Parallel,
PanGesture()
.onActionUpdate(() => {
this.statusText = '拖拽识别中'
}),
PinchGesture()
.onActionUpdate(() => {
this.statusText = '缩放识别中'
})
)
)
}
}
.width('100%')
.padding(20)
}
}6.5.3 4.4.3 拖拽与滑动交互
@Component
struct DragDropDemo {
@State items: DragItem[] = [
new DragItem('1', '项目 A', '#FF6B6B'),
new DragItem('2', '项目 B', '#4ECDC4'),
new DragItem('3', '项目 C', '#45B7D1'),
new DragItem('4', '项目 D', '#96CEB4'),
]
@State dragIndex: number = -1
@State dropIndex: number = -1
build() {
Column() {
Text('拖拽排序列表')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 16 })
ForEach(this.items, (item: DragItem, index: number) => {
Row() {
Text(item.label)
.fontSize(16)
.fontColor(Color.White)
.layoutWeight(1)
}
.width('100%')
.height(60)
.padding({ left: 16, right: 16 })
.backgroundColor(item.color)
.borderRadius(8)
.margin({ bottom: 8 })
.opacity(this.dragIndex === index ? 0.5 : 1.0)
// 拖拽能力
.dragable(true)
.onDragStart(() => {
this.dragIndex = index
// 返回拖拽预览
return this.getDragPreview(item)
})
.onDrop((event: DragEvent, extraParams: string) => {
this.dropIndex = index
this.reorderItems(this.dragIndex, this.dropIndex)
this.dragIndex = -1
this.dropIndex = -1
})
}, (item: DragItem) => item.id)
}
.width('100%')
.padding(16)
}
@Builder
getDragPreview(item: DragItem): void {
// 此处返回自定义拖拽预览(实际返回像素图)
}
private reorderItems(from: number, to: number): void {
if (from === to || from < 0 || to < 0) return
const temp = this.items[from]
const newItems = [...this.items]
newItems.splice(from, 1)
newItems.splice(to, 0, temp)
this.items = newItems
}
}
class DragItem {
id: string
label: string
color: string
constructor(id: string, label: string, color: string) {
this.id = id
this.label = label
this.color = color
}
}6.6 4.5 自定义布局
6.6.1 4.5.1 Layout接口实现
ArkUI提供了自定义布局接口,允许开发者实现完全自定义的布局逻辑:
// 自定义流式布局
@Builder
function FlowLayoutBuilder() {
FlowLayout({ space: 10 }) {
ForEach(['HarmonyOS', 'ArkTS', '分布式', '软总线',
'超级终端', '跨设备', '原子化服务', '元服务',
'Stage模型', '声明式UI'], (tag: string) => {
Text(tag)
.fontSize(14)
.fontColor(Color.White)
.backgroundColor('#4FC3F7')
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
}, (tag: string) => tag)
}
}
// 流式布局组件实现
@Component
struct FlowLayout {
@BuilderParam content: () => void = this.defaultBuilder
space: number = 10
@Builder
defaultBuilder() {}
build() {
Column() {
this.content()
}
// 使用 measure 和 layout 回调实现自定义布局
.onAreaChange((oldArea: Area, newArea: Area) => {
// 容器尺寸变化时重新布局
})
}
}使用自定义Layout接口的流式布局:
// 通过 Layout 接口实现真正的自定义布局
@Component
struct CustomFlowLayout {
space: number = 8
@BuilderParam content: () => void = this.defaultContent
@Builder
defaultContent() {}
build() {
Layout(this) {
this.content()
}
}
// 实现 Layout 接口的 measure 方法
measure(childIds: number[], constraint: ConstraintSize): MeasureResult {
const containerWidth = constraint.maxWidth ?? 360
let currentX = 0
let currentY = 0
let lineHeight = 0
let totalHeight = 0
for (const childId of childIds) {
const childSize = this.getChildMeasureSize(childId)
const childWidth = childSize.width
const childHeight = childSize.height
// 如果当前行放不下,换行
if (currentX + childWidth > containerWidth && currentX > 0) {
currentX = 0
currentY += lineHeight + this.space
lineHeight = 0
}
// 记录子组件位置
this.setChildPosition(childId, currentX, currentY)
currentX += childWidth + this.space
lineHeight = Math.max(lineHeight, childHeight)
totalHeight = currentY + lineHeight
}
return {
width: containerWidth,
height: totalHeight
}
}
// 实现 Layout 接口的 place 方法
place(childIds: number[]): void {
for (const childId of childIds) {
const pos = this.getChildPosition(childId)
this.placeChild(childId, pos.x, pos.y)
}
}
private getChildMeasureSize(childId: number):
{ width: number, height: number } {
// 获取子组件的测量尺寸
return { width: 100, height: 40 }
}
private setChildPosition(childId: number, x: number, y: number): void {
// 存储子组件位置
}
private getChildPosition(childId: number): { x: number, y: number } {
return { x: 0, y: 0 }
}
private placeChild(childId: number, x: number, y: number): void {
// 放置子组件到指定位置
}
}6.6.2 4.5.2 瀑布流布局实现
// 瀑布流数据模型
@Observed
class WaterfallItem {
id: number
title: string
height: number // 内容高度
color: string
imageUri: string
constructor(id: number, title: string, height: number,
color: string, imageUri: string = '') {
this.id = id
this.title = title
this.height = height
this.color = color
this.imageUri = imageUri
}
}
// 瀑布流数据源
class WaterfallDataSource implements IDataSource {
private items: WaterfallItem[] = []
private listeners: DataChangeListener[] = []
public totalCount(): number {
return this.items.length
}
public getData(index: number): WaterfallItem {
return this.items[index]
}
public registerDataChangeListener(
listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener)
}
}
public unregisterDataChangeListener(
listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener)
if (pos >= 0) {
this.listeners.splice(pos, 1)
}
}
public addData(index: number, data: WaterfallItem): void {
this.items.splice(index, 0, data)
this.notifyDataAdd(index, 1)
}
public loadData(newItems: WaterfallItem[]): void {
this.items = [...this.items, ...newItems]
this.notifyDataAdd(this.items.length - newItems.length, newItems.length)
}
private notifyDataAdd(index: number, count: number): void {
for (const listener of this.listeners) {
listener.onDataAdd(index, count)
}
}
}
// 瀑布流组件
@Component
struct WaterfallLayout {
@State dataSource: WaterfallDataSource = new WaterfallDataSource()
columnCount: number = 2
columnGap: number = 8
columnHeights: number[] = [0, 0]
aboutToAppear(): void {
// 加载模拟数据
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
'#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F']
const items: WaterfallItem[] = []
for (let i = 0; i < 30; i++) {
const height = 120 + Math.floor(Math.random() * 180)
const color = colors[i % colors.length]
items.push(new WaterfallItem(i, `内容 ${i + 1}`, height, color))
}
this.dataSource.loadData(items)
}
build() {
WaterFlow() {
LazyForEach(this.dataSource, (item: WaterfallItem) => {
FlowItem() {
Column() {
// 模拟不同高度的内容
Column()
.width('100%')
.height(item.height)
.backgroundColor(item.color)
.borderRadius(8)
Text(item.title)
.fontSize(14)
.margin({ top: 6 })
.padding({ left: 4, right: 4 })
}
.padding(4)
}
}, (item: WaterfallItem) => `waterfall_${item.id}`)
}
.columnsTemplate('1fr 1fr')
.columnsGap(this.columnGap)
.rowsGap(this.columnGap)
.width('100%')
.height('100%')
}
}6.7 4.6 ArkUI声明式语法增强
6.7.1 4.6.1 条件渲染与循环渲染优化
条件渲染最佳实践:
@Component
struct ConditionalRenderOptimization {
@State userType: 'guest' | 'member' | 'admin' = 'guest'
@State showDetails: boolean = false
build() {
Column() {
// 优化1:使用 if-else 而非三元表达式控制复杂UI
if (this.userType === 'admin') {
AdminPanel()
.transition(TransitionEffect.OPACITY)
} else if (this.userType === 'member') {
MemberPanel()
.transition(TransitionEffect.OPACITY)
} else {
GuestPanel()
.transition(TransitionEffect.OPACITY)
}
// 优化2:避免在循环中使用条件渲染
// 反模式
// ForEach(this.list, (item) => {
// if (item.isVisible) {
// ListItem() { ... }
// }
// })
// 正确做法:先过滤再渲染
// ForEach(this.getVisibleItems(), (item) => {
// ListItem() { ... }
// })
}
}
private getVisibleItems(): DataItem[] {
return [] // 返回过滤后的数据
}
}
@Component struct AdminPanel {
build() { Text('管理员面板').fontSize(18) }
}
@Component struct MemberPanel {
build() { Text('会员面板').fontSize(18) }
}
@Component struct GuestPanel {
build() { Text('访客面板').fontSize(18) }
}6.7.2 4.6.2 LazyForEach深入
LazyForEach 是处理大数据列表的关键组件,它按需创建子组件,避免一次性渲染所有数据。
// 实现完整的 IDataSource 接口
class BasicDataSource implements IDataSource {
private data: string[] = []
private listeners: DataChangeListener[] = []
totalCount(): number {
return this.data.length
}
getData(index: number): string {
return this.data[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener)
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener)
if (pos >= 0) {
this.listeners.splice(pos, 1)
}
}
// 数据变更通知方法
notifyDataReload(): void {
this.listeners.forEach(listener => {
listener.onDataReload()
})
}
notifyDataAdd(index: number, count: number): void {
this.listeners.forEach(listener => {
listener.onDataAdd(index, count)
})
}
notifyDataChange(index: number): void {
this.listeners.forEach(listener => {
listener.onDataChange(index)
})
}
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => {
listener.onDataDelete(index)
})
}
// 设置数据
setData(newData: string[]): void {
this.data = newData
this.notifyDataReload()
}
appendData(newItems: string[]): void {
const startIndex = this.data.length
this.data = [...this.data, ...newItems]
this.notifyDataAdd(startIndex, newItems.length)
}
}
@Component
struct LazyForEachDemo {
private dataSource: BasicDataSource = new BasicDataSource()
@State isLoadingMore: boolean = false
@State totalCount: number = 0
aboutToAppear(): void {
// 初始加载
const initialData: string[] = []
for (let i = 0; i < 50; i++) {
initialData.push(`列表项 ${i + 1}`)
}
this.dataSource.setData(initialData)
this.totalCount = initialData.length
}
build() {
Column() {
Text(`总数: ${this.totalCount}`)
.fontSize(16)
.margin({ bottom: 8 })
List() {
LazyForEach(this.dataSource, (item: string, index: number) => {
ListItem() {
Row() {
Text(`${index + 1}. ${item}`)
.fontSize(16)
.layoutWeight(1)
}
.width('100%')
.height(60)
.padding({ left: 16, right: 16 })
.borderRadius(8)
.margin({ bottom: 4 })
.backgroundColor(index % 2 === 0 ? '#F5F5F5' : Color.White)
}
}, (item: string, index: number) => `item_${index}_${item}`)
}
.width('100%')
.layoutWeight(1)
// 滚动到底部时加载更多
.onReachEnd(() => {
this.loadMore()
})
if (this.isLoadingMore) {
LoadingProgress()
.width(40)
.height(40)
}
}
.width('100%')
.height('100%')
.padding(16)
}
private async loadMore(): Promise<void> {
if (this.isLoadingMore) return
this.isLoadingMore = true
// 模拟网络请求
const newItems: string[] = []
const start = this.totalCount
for (let i = 0; i < 20; i++) {
newItems.push(`列表项 ${start + i + 1}`)
}
this.dataSource.appendData(newItems)
this.totalCount += newItems.length
this.isLoadingMore = false
}
}6.7.3 4.6.3 组件缓存与复用策略
@Component
struct CachedListItem {
@Prop itemData: ListItemModel = new ListItemModel('', '', '')
@State isImageLoaded: boolean = false
build() {
Row({ space: 12 }) {
// 图片加载与缓存
Image(this.itemData.imageUrl)
.width(60)
.height(60)
.borderRadius(8)
.objectFit(ImageFit.Cover)
.onComplete(() => {
this.isImageLoaded = true
})
.alt($r('app.media.placeholder')) // 占位图
Column({ space: 4 }) {
Text(this.itemData.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.itemData.subtitle)
.fontSize(13)
.fontColor('#666666')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}
}
class ListItemModel {
id: string
title: string
subtitle: string
imageUrl: string
constructor(id: string, title: string, subtitle: string,
imageUrl: string = '') {
this.id = id
this.title = title
this.subtitle = subtitle
this.imageUrl = imageUrl
}
}组件缓存策略配置:
// 使用 componentCache 控制组件缓存
@Component
struct CacheStrategyDemo {
private dataSource: CachedDataSource = new CachedDataSource()
build() {
List() {
LazyForEach(this.dataSource, (item: ListItemModel) => {
ListItem() {
CachedListItem({ itemData: item })
}
.cachedCount(5) // 缓存屏幕外5个组件
}, (item: ListItemModel) => item.id)
}
.cachedCount(5) // List级别的缓存数量
.width('100%')
.height('100%')
.divider({
strokeWidth: 0.5,
color: '#EEEEEE',
startMargin: 84,
endMargin: 12
})
}
}6.8 本章小结
本章深入讲解了HarmonyOS ArkUI框架的高级UI开发技术:
自定义组件:掌握了组件生命周期的三个阶段(
aboutToAppear、aboutToDisappear、onDidBuild),理解了不同装饰器的数据流向差异,学会了通过组件拆分和@Watch进行性能优化。自定义绘制:掌握了Canvas绑定模式的使用方法,能够使用路径、贝塞尔曲线、变换和渐变绘制复杂图形,理解了离屏渲染与缓存策略对性能的提升。
高级动画系统:掌握了属性动画与显式动画的区别,能够实现页面转场、组件转场、弹簧物理动画和帧动画。
手势与交互:理解了ArkUI手势系统的完整架构,掌握了手势组合(顺序、并行、互斥)的使用方法和拖拽交互的实现。
自定义布局:学会了通过Layout接口实现自定义布局,掌握了流式布局和瀑布流布局的实现方案。
声明式语法增强:深入理解了LazyForEach的工作机制,掌握了IDataSource接口的完整实现和组件缓存策略。
6.9 练习题
6.9.1 一、单选题
1. 在ArkUI自定义组件中,以下哪个生命周期回调在 build() 方法之前执行?
A. onDidBuild() B. aboutToDisappear() C. aboutToAppear() D. onPageShow()
答案:C
解析: aboutToAppear() 在组件即将创建时调用,在 build() 方法执行之前运行。onDidBuild() 在 build() 之后调用。aboutToDisappear() 在组件销毁时调用。onPageShow() 是页面级生命周期,不属于自定义组件生命周期。
2. 以下哪种装饰器支持嵌套对象的深层属性监听?
A. @Prop B. @Link C. @Observed + @ObjectLink D. @Provide + @Consume
答案:C
解析: @Observed 标记类使其属性可被观察,@ObjectLink 在子组件中接收被 @Observed 标记的对象,两者配合使用可以实现嵌套对象属性的深层监听。当嵌套对象的属性发生变化时,使用 @ObjectLink 的子组件会自动刷新。
3. 在Canvas绑定模式中,创建 CanvasRenderingContext2D 时需要传入什么参数?
A. Canvas组件的引用 B. RenderingContextSettings 对象 C. 画布宽高尺寸 D. 渲染模式枚举值
答案:B
解析: 创建 CanvasRenderingContext2D 时需要传入 RenderingContextSettings 对象,该对象用于配置渲染上下文设置,如是否开启抗锯齿等。
4. 以下哪种手势组合模式表示”互斥识别”?
A. GestureMode.Sequence B. GestureMode.Parallel C. GestureMode.Exclusive D. GestureMode.Alternative
答案:C
解析: GestureMode.Exclusive 表示互斥识别模式,只会识别第一个匹配成功的手势。Sequence 是顺序识别,Parallel 是并行识别。
6.9.2 二、多选题
5. 以下哪些是ArkUI提供的内置手势识别器?
A. TapGesture B. LongPressGesture C. DoubleTapGesture D. PinchGesture E. RotationGesture
答案:A、B、D、E
解析: ArkUI内置手势包括 TapGesture(点击,可通过count参数设置双击)、LongPressGesture(长按)、PanGesture(拖拽)、PinchGesture(捏合)、RotationGesture(旋转)、SwipeGesture(滑动)。没有单独的 DoubleTapGesture,双击通过 TapGesture({ count: 2 }) 实现。
6. 关于 LazyForEach 的描述,以下哪些是正确的?
A. 按需创建子组件,避免一次性渲染所有数据 B. 需要配合实现 IDataSource 接口的数据源使用 C. 数据变更时必须调用 notifyDataReload() 通知刷新 D. 可以通过 keyGenerator 参数指定唯一键值生成规则 E. 只支持 List 组件,不支持其他容器
答案:A、B、D
解析: LazyForEach 确实按需创建子组件(A正确),需要 IDataSource 数据源(B正确),支持自定义键值生成器(D正确)。数据变更时可以根据情况调用不同的通知方法(增/删/改/重载),不一定必须调用 notifyDataReload()(C错误)。LazyForEach 可用于 List、Grid、WaterFlow 等多种容器(E错误)。
6.9.3 三、判断题
7. 在ArkUI中,@Prop 装饰器传递的是数据的引用,子组件对数据的修改会影响父组件。
答案:错误
解析: @Prop 传递的是数据的副本(深拷贝),子组件对 @Prop 修饰的数据进行修改不会影响父组件的原始数据。如果需要双向同步,应使用 @Link 装饰器。
8. 使用 animateTo 进行显式动画时,状态变量的修改必须放在 animateTo 的回调函数中。
答案:正确
解析: animateTo 的第二个参数是一个无参回调函数,所有需要动画效果的状态变量修改都必须放在这个回调中。框架会检测回调中状态变量的变化,并自动应用动画效果。