HarmonyOS NEXT Panel+onChange 深度实践:从底部面板拖拽到状态监听的全链路解析


1. 引言:为什么需要 Panel 面板组件
在移动应用开发中,"从底部滑出一个面板"是极为常见的交互模式。无论是电商商品的详情页、资讯类应用的评论区域、地图应用中的路线信息,还是社交应用的分享面板,底部面板都提供了比全屏跳转更轻量、比对话框更灵活的交互方式。
在 HarmonyOS 生态中,ArkUI 框架提供了一个专门的原生组件——Panel,用于实现这种"底部可拖拽面板"的布局模式。与早期版本需要开发者手写动画、监听触摸事件不同,Panel 组件以声明式的方式,通过寥寥几行属性链配置,即可完成从底部滑出、拖拽切换高度、遮罩背景等一系列复杂交互。
但在实际开发中,仅仅会使用 Panel 的"显示与隐藏"还远远不够。真正成熟的应用需要监听用户拖拽面板的整个过程——面板当前是 Mini 还是 Half?面板的实时宽度和高度是多少?用户拖拽到哪个位置停下了?这些信息在构建"根据面板状态动态加载内容"的场景中至关重要。
这就是 onChange 回调 的核心价值所在。本文将通过两个由浅入深的示例项目——基础版 PanelDemo 和监听版 PanelOnChangeDemo——全方位解析 Panel 组件的声明式用法与 onChange 回调机制。
2. Panel 组件全景概览
2.1 什么是 Panel
Panel 是一个从屏幕底部滑出的可拖拽面板容器。它支持三种预定义模式(Mini / Half / Full),用户可以通过拖拽面板顶部的 dragBar 在这些模式之间自由切换,开发者也可以通过代码直接设置面板模式。
2.2 核心 API 速查表
| API | 类型 | 说明 | SDK 版本 |
|---|---|---|---|
Panel(show: boolean) |
构造参数 | 面板显隐控制,true 显示,false 隐藏 | API 7+ |
.mode(value: PanelMode) |
属性 | 面板模式:Mini / Half / Full | API 7+ |
.type(value: PanelType) |
属性 | 面板类型:Foldable / Minibar / Temporary / CUSTOM | API 10+ |
.dragBar(value: boolean) |
属性 | 是否显示拖拽指示条 | API 7+ |
.halfHeight(value: number) |
属性 | Half 模式下的面板高度(vp) | API 7+ |
.show(value: boolean) |
属性 | 面板显隐(优先级高于构造参数) | API 7+ |
.onChange(callback) |
回调 | 面板宽/高/模式变化时触发 | API 7+ |
.onAppear(callback) |
回调 | 面板入场动画开始时触发 | API 7+ |
.onDisAppear(callback) |
回调 | 面板退场动画结束时触发 | API 7+ |
.borderRadius(value) |
属性 | 面板圆角 | API 7+ |
2.3 PanelMode 枚举
enum PanelMode {
Mini = 0, // 最小化模式,通常仅显示拖拽条+一行文字
Half = 1, // 半屏模式,屏幕的一半高度
Full = 2, // 全屏模式,几乎覆盖整个屏幕(留出状态栏空间)
}
2.4 PanelType 枚举
enum PanelType {
Minibar = 0, // 迷你条模式,仅 Mini 和 Full 两种状态
Foldable = 1, // 可折叠模式,支持 Mini / Half / Full 三种状态
Temporary = 2, // 临时面板,支持 Half / Full 两种状态(无 Mini)
CUSTOM = 3, // 自定义高度面板(需配合 customHeight 属性)
}
2.5 Panel 与 Dialog 的对比
| 维度 | Panel | Dialog |
|---|---|---|
| 交互方式 | 可拖拽,自然手势 | 点击按钮弹窗 |
| 状态切换 | 三种高度模式无缝切换 | 弹窗/关闭二态 |
| 遮罩层 | 可配置 | 必须 |
| 内容承载 | 支持 Scroll / 复杂布局 | 有限的内容空间 |
| 适用场景 | 详情、评论、设置面板 | 确认、提示、简单输入 |
从对比可以看出,Panel 更适合承载"信息量较大、用户可能需要反复查看"的内容,而 Dialog 更适合"一次性的确认或输入操作"。
3. 项目一:基础面板拉出布局——PanelDemo
3.1 需求描述
创建一个文章阅读页面,底部包含一个 Panel 面板。文章内容展示在主体区域,面板用于显示评论列表和输入框。Panel 支持三种模式切换:Mini(缩略条)、Half(半屏评论列表)、Full(全屏评论详情)。
3.2 完整代码
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct PanelDemo {
@State panelMode: PanelMode = PanelMode.Half;
@State showDragBar: boolean = true;
@State showPanel: boolean = true;
@State commentText: string = '';
@State isFavorite: boolean = false;
@State halfHeightRatio: number = 0.5;
private readonly articleTitle: string = '鸿蒙 ArkTS 布局指南';
private readonly articleContent: string =
'HarmonyOS NEXT 提供了丰富的原生布局组件,其中 Panel 面板组件用于实现'
+ '从底部滑出的可交互面板,广泛应用于评论、详情、设置等二级视图场景。'
+ '\n\nPanel 的核心特性包括:\n'
+ '• dragBar:面板顶部的拖拽指示条,用户可拖拽切换面板高度\n'
+ '• showMode:三种状态模式(Mini / Half / Full)\n'
+ '• 支持遮罩层,点击遮罩自动收起面板\n'
+ '• 支持自定义半屏高度比例\n\n'
+ '在实现移动端交互时,Panel 是替代弹窗(Dialog)的优选方案,'
+ '因为它提供了更自然的"拉出"动效和更灵活的高度控制。';
build() {
Stack() {
// ── 主内容区 ──
Column() {
Row() {
Text('📄 ' + this.articleTitle)
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
}
.width('100%').height(52).backgroundColor('#2C3E50')
.justifyContent(FlexAlign.Center)
Scroll() {
Column() {
Row() {
Text('📖').fontSize(48)
Text('HarmonyOS NEXT').fontSize(20)
.fontWeight(FontWeight.Bold).fontColor('#2C3E50').margin({ left: 12 })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 24, bottom: 16 })
Text(this.articleContent)
.fontSize(15).fontColor('#444444').lineHeight(26)
.textAlign(TextAlign.Start)
Row() {
Button() {
Text(this.isFavorite ? '❤️' : '🤍').fontSize(18).margin({ right: 4 })
Text(this.isFavorite ? '已收藏' : '收藏').fontSize(14).fontColor('#FFFFFF')
}
.type(ButtonType.Capsule)
.backgroundColor(this.isFavorite ? '#E74C3C' : '#95A5A6')
.height(40)
.onClick(() => { this.isFavorite = !this.isFavorite; })
Blank().layoutWeight(1)
Button() {
Text('💬').fontSize(18).margin({ right: 4 })
Text('打开评论面板').fontSize(14).fontColor('#FFFFFF')
}
.type(ButtonType.Capsule).backgroundColor('#3498DB').height(40)
.onClick(() => { this.panelMode = PanelMode.Half; })
}
.width('100%').padding({ top: 16, bottom: 40 })
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
}
.layoutWeight(1).backgroundColor('#F5F7FA')
}
.width('100%').height('100%')
// ── Panel 底部面板 ──
Panel(this.showPanel) {
Column() {
if (this.panelMode === PanelMode.Mini) {
this.buildMiniContent()
} else if (this.panelMode === PanelMode.Half) {
this.buildHalfContent()
} else if (this.panelMode === PanelMode.Full) {
this.buildFullContent()
}
}
.width('100%').height('100%').backgroundColor('#FFFFFF')
}
.mode(this.panelMode)
.dragBar(this.showDragBar)
.type(PanelType.Foldable)
.halfHeight(this.halfHeightRatio)
.show(this.showPanel)
.onChange((width: number, height: number, mode: PanelMode) => {
this.panelMode = mode;
})
.margin({ bottom: 0 })
.borderRadius({ topLeft: 20, topRight: 20 })
.backgroundMask(Color.Transparent)
}
.width('100%').height('100%')
}
@Builder
buildMiniContent(): void {
Row() {
Text('📌 评论面板已收起').fontSize(14).fontColor('#888888')
Blank().layoutWeight(1)
Text('⬆ 上滑展开').fontSize(12).fontColor('#3498DB')
}
.width('100%').padding({ left: 20, right: 20, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
}
@Builder
buildHalfContent(): void {
Column() {
Row() {
Text('💬 全部评论').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#2C3E50')
Blank().layoutWeight(1)
Text('共 4 条').fontSize(13).fontColor('#999999')
}
.width('100%').padding({ left: 20, right: 20, top: 12, bottom: 8 })
Scroll() {
Column() {
ForEach(this.getComments(), (comment: CommentItem, index: number) => {
this.buildCommentCard(comment, index)
})
this.buildCommentInput()
}
.width('100%').padding({ left: 16, right: 16 })
}
.layoutWeight(1)
}
.width('100%').height('100%').backgroundColor('#FFFFFF')
}
// ...(buildFullContent, buildCommentCard, buildCommentInput, getComments 方法
// 与 buildHalfContent 类似,为节省篇幅此处省略,详见完整源文件)
}
interface CommentItem {
avatar: string;
name: string;
time: string;
content: string;
likes: number;
}
3.3 布局结构分析
此页面的布局是一个典型的 Stack 层叠布局:
Stack(根容器)
├── Column(主内容层——背景文章)
│ ├── Row(顶部标题栏,固定高度 52vp)
│ ├── Scroll(文章正文,layoutWeight 填充剩余空间)
│ │ └── Column
│ │ ├── 文章头部装饰
│ │ ├── 正文文本
│ │ └── 操作按钮行(收藏 + 打开面板)
│ └── (省略)
└── Panel(面板层——浮动在内容之上)
└── Column
├── Mini 模式:缩略提示条
├── Half 模式:评论列表 + 输入框
└── Full 模式:文章摘要 + 评论列表 + 输入框
关键设计决策是使用 Stack 而不是 Column。这是因为 Panel 需要浮在内容之上,从底部滑出时覆盖而不是推挤内容。如果用 Column 包含 Panel,当 Panel 展开时主体内容会被向上推挤,产生不自然的体验。
3.4 核心属性链解读
Panel(this.showPanel) // ① 构造参数:boolean 型,控制显隐
.mode(this.panelMode) // ② 设置面板模式
.dragBar(true) // ③ 显示拖拽条
.type(PanelType.Foldable) // ④ 可折叠类型(支持三种模式切换)
.halfHeight(0.5) // ⑤ Half 模式高度(0.5 表示屏幕高度的 50%)
.show(true) // ⑥ 显隐属性(优先级高于构造参数)
.onChange(callback) // ⑦ 变化回调
.borderRadius({ topLeft: 20, topRight: 20 }) // ⑧ 顶部圆角
① 和 ⑥ 看似重复,但 .show() 的优先级高于构造参数。当你在构造参数中传 false、但之后通过 .show(true) 覆盖时,面板会显示。这种双重控制机制提供了灵活度——构造参数决定初始状态,.show() 属性可以在后续动态调整。
4. 项目二:onChange 拖拽状态监听——PanelOnChangeDemo
4.1 需求描述
创建一个面板状态实时监控页面。用户打开底部面板并拖拽 dragBar 时,页面上方的监控面板实时展示:
- 面板的实时宽度和高度(vp 单位)
- 当前面板模式(Mini / Half / Full)
- onChange 回调触发的累计次数
- 面板动画阶段(hidden / entering / shown / exiting)
- 每次 onChange 触发的历史日志
4.2 设计思路
与基础版 PanelDemo 不同,PanelOnChangeDemo 的核心关注点不是"面板里装什么内容",而是**“面板的状态变化如何被监听和展示”**。因此,我们设计了三个层次的信息展示区:
- 控制区(Control Section):打开/关闭面板、一键切换到 Half/Full 模式
- 监控区(Monitor Section):6 张状态卡片,实时刷新 onChange 上报的数据
- 日志区(Log Section):最近 8 次 onChange 触发的详细记录,含时间戳
这三个区域全部位于主内容区(位于 Panel 之上),而 Panel 内部则展示对应模式下的内容作为交互反馈。
4.3 完整代码
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct PanelOnChangeDemo {
// ============================================================
// 状态变量
// ============================================================
@State panelShow: boolean = false;
@State currentMode: PanelMode = PanelMode.Half;
@State panelWidth: number = 0;
@State panelHeight: number = 0;
@State panelModeName: string = '--';
@State onChangeCount: number = 0;
@State animPhase: string = 'hidden';
@State changeLog: string[] = [];
private readonly panelType: PanelType = PanelType.Foldable;
private animTimerId: number = -1;
build() {
Column() {
// ── 标题栏 ──
Row() {
Text('📊 Panel + onChange 监听演示')
.fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
}
.width('100%').height(52).backgroundColor('#1A1A2E')
.justifyContent(FlexAlign.Center)
// ── 主内容区 ──
Scroll() {
Column() {
this.buildControlSection()
this.buildMonitorSection()
this.buildLogSection()
Blank().height(30)
}
.width('100%').padding(16)
}
.layoutWeight(1).backgroundColor('#0F3460')
// ── Panel 底部面板 ──
Panel(this.panelShow) {
Column() {
if (this.currentMode === PanelMode.Mini) {
this.buildPanelMiniContent()
} else if (this.currentMode === PanelMode.Half) {
this.buildPanelHalfContent()
} else if (this.currentMode === PanelMode.Full) {
this.buildPanelFullContent()
}
}
.width('100%').height('100%').backgroundColor('#FFFFFF')
}
.type(this.panelType)
.mode(this.currentMode)
.dragBar(true)
.halfHeight(400)
.show(true)
// ★ 核心:onChange 回调
.onChange((width: number, height: number, mode: PanelMode) => {
this.panelWidth = width;
this.panelHeight = height;
this.currentMode = mode;
this.onChangeCount++;
let modeText: string = 'unknown';
if (mode === PanelMode.Mini) {
modeText = 'Mini(最小化)';
} else if (mode === PanelMode.Half) {
modeText = 'Half(半屏)';
} else if (mode === PanelMode.Full) {
modeText = 'Full(全屏)';
}
this.panelModeName = modeText;
const now: string = new Date().toLocaleTimeString();
const logEntry: string =
`[${now}] 宽=${width.toFixed(0)} 高=${height.toFixed(0)} 模式=${modeText}`;
this.changeLog = [logEntry, ...this.changeLog].slice(0, 8);
})
.onAppear(() => {
this.animPhase = 'entering';
clearTimeout(this.animTimerId);
this.animTimerId = setTimeout(() => {
this.animPhase = 'shown';
}, 400);
})
.onDisAppear(() => {
this.animPhase = 'hidden';
clearTimeout(this.animTimerId);
})
.borderRadius({ topLeft: 20, topRight: 20 })
.backgroundMask(Color.Transparent)
}
.width('100%').height('100%').backgroundColor('#0F3460')
}
// ============================================================
// @Builder 方法
// ============================================================
@Builder
buildControlSection(): void {
Column() {
Text('拖拽面板实时状态监听')
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#E8D5B7')
.width('100%').margin({ bottom: 6 })
Text('拖拽底部面板的 dragBar 或在三种模式间切换,'
+ '下方监控面板将实时显示 onChange 回调上报的宽度、高度和模式变化。')
.fontSize(13).fontColor('#8899AA').lineHeight(20)
.width('100%').margin({ bottom: 16 })
Row() {
Button() {
Text(this.panelShow ? '✕ 关闭面板' : '📂 打开面板')
.fontSize(15).fontColor(Color.White)
}
.type(ButtonType.Capsule)
.backgroundColor(this.panelShow ? '#E74C3C' : '#3498DB')
.height(44)
.onClick(() => {
this.panelShow = !this.panelShow;
if (!this.panelShow) { this.changeLog = []; }
})
Blank().layoutWeight(1)
Button() {
Text('➡ Half').fontSize(13).fontColor(Color.White)
}
.type(ButtonType.Capsule).backgroundColor('#2ECC71')
.height(40).width(80)
.enabled(this.panelShow)
.onClick(() => { this.currentMode = PanelMode.Half; })
Blank().width(8)
Button() {
Text('➡ Full').fontSize(13).fontColor(Color.White)
}
.type(ButtonType.Capsule).backgroundColor('#9B59B6')
.height(40).width(80)
.enabled(this.panelShow)
.onClick(() => { this.currentMode = PanelMode.Full; })
}
.width('100%')
}
.width('100%').backgroundColor('#16213E')
.borderRadius(12).padding(16).margin({ bottom: 12 })
}
@Builder
buildMonitorSection(): void {
Column() {
Text('📡 onChange 实时数据')
.fontSize(16).fontWeight(FontWeight.Bold).fontColor('#E8D5B7')
.width('100%').margin({ bottom: 12 })
// 第 1 行:面板状态 + 动画阶段
Row() {
this.buildStatusCard(
'面板状态',
this.panelShow ? '🟢 已打开' : '🔴 已关闭',
this.panelShow ? '#27AE60' : '#E74C3C'
)
this.buildStatusCard(
'动画阶段',
this.getAnimPhaseText(),
this.getAnimPhaseColor()
)
}
.width('100%')
// 第 2 行:宽度 / 高度 / 模式
Row() {
this.buildStatusCard('宽度', this.panelWidth.toFixed(0) + ' vp', '#3498DB')
this.buildStatusCard('高度', this.panelHeight.toFixed(0) + ' vp', '#2ECC71')
this.buildStatusCard('模式', this.panelModeName || '--', '#E67E22')
}
.width('100%')
// 第 3 行:触发次数 + 日志条数
Row() {
this.buildStatusCard('onChange 触发次数', this.onChangeCount + ' 次', '#9B59B6')
this.buildStatusCard('日志条数', this.changeLog.length + ' / 8', '#1ABC9C')
}
.width('100%')
}
.width('100%').backgroundColor('#1A1A2E')
.borderRadius(12).padding(16).margin({ bottom: 12 })
}
@Builder
buildStatusCard(label: string, value: string, color: string): void {
Column() {
Text(label).fontSize(11).fontColor('#8899AA').margin({ bottom: 4 })
Text(value).fontSize(15).fontWeight(FontWeight.Bold).fontColor(color)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 12, bottom: 12 }).margin(3)
.backgroundColor('#16213E').borderRadius(8)
}
@Builder
buildLogSection(): void {
Column() {
Row() {
Text('📝 onChange 变化日志')
.fontSize(16).fontWeight(FontWeight.Bold).fontColor('#E8D5B7')
Blank().layoutWeight(1)
Button() {
Text('清空').fontSize(12).fontColor(Color.White)
}
.type(ButtonType.Capsule).backgroundColor('#E74C3C')
.height(28).fontSize(12)
.enabled(this.changeLog.length > 0)
.onClick(() => { this.changeLog = []; })
}
.width('100%').margin({ bottom: 8 })
if (this.changeLog.length === 0) {
Text('暂无数据,请打开并拖拽面板')
.fontSize(13).fontColor('#667788')
.width('100%').textAlign(TextAlign.Center)
.padding({ top: 24, bottom: 24 })
} else {
Column() {
ForEach(this.changeLog, (log: string, index: number) => {
Row() {
Text('#' + (this.changeLog.length - index))
.fontSize(11).fontColor('#3498DB').width(28)
.textAlign(TextAlign.End).margin({ right: 8 })
Text(log).fontSize(12).fontColor('#CCCCCC').fontFamily('monospace')
}
.width('100%')
.padding({ top: 6, bottom: 6 })
.border({ width: { bottom: 1 }, color: '#2A3A5A',
style: BorderStyle.Solid })
})
}
.width('100%').backgroundColor('#16213E')
.borderRadius(8).padding({ left: 12, right: 12 })
}
}
.width('100%').backgroundColor('#1A1A2E')
.borderRadius(12).padding(16).margin({ bottom: 12 })
}
// ── Panel 内部内容构建器 ──
@Builder
buildPanelMiniContent(): void {
Row() {
Text('📌 面板已收起 — 上滑展开查看完整信息')
.fontSize(13).fontColor('#888888')
Blank().layoutWeight(1)
Text('⬆ 拖拽展开').fontSize(12).fontColor('#3498DB')
}
.width('100%').padding({ left: 20, right: 20, top: 12, bottom: 12 })
}
@Builder
buildPanelHalfContent(): void {
Column() {
Row() {
Text('📋 Panel 状态详情 — Half 模式')
.fontSize(16).fontWeight(FontWeight.Bold).fontColor('#2C3E50')
Blank().layoutWeight(1)
Text('⬆ 上滑全屏').fontSize(12).fontColor('#3498DB')
}
.width('100%').padding({ left: 20, right: 20, top: 12, bottom: 12 })
Scroll() {
Column() {
this.buildPanelInfoRow('📐 当前宽度',
this.panelWidth.toFixed(0) + ' vp')
this.buildPanelInfoRow('📏 当前高度',
this.panelHeight.toFixed(0) + ' vp')
this.buildPanelInfoRow('🔘 当前模式', this.panelModeName)
this.buildPanelInfoRow('🔄 触发次数', this.onChangeCount + ' 次')
this.buildPanelInfoRow('⏱ 动画阶段', this.getAnimPhaseText())
Blank().height(16)
Column() {
Text('💡 交互提示').fontSize(14)
.fontWeight(FontWeight.Bold).fontColor('#2C3E50')
.margin({ bottom: 8 })
Text('1. 拖拽上方的 dragBar 上下滑动,观察 onChange 实时更新\n'
+ '2. 点击顶部"Half"或"Full"按钮,面板自动切换模式\n'
+ '3. 点击"打开/关闭面板"控制显隐\n'
+ '4. 每次 onChange 触发都会记录到日志中')
.fontSize(13).fontColor('#666666').lineHeight(20)
}
.width('100%').backgroundColor('#F8F9FA')
.borderRadius(12).padding(16)
}
.width('100%').padding({ left: 16, right: 16, bottom: 20 })
}
.layoutWeight(1)
}
.width('100%').height('100%').backgroundColor('#FFFFFF')
}
// ...(buildPanelFullContent、buildPanelInfoRow、
// buildPanelInfoRowLarge 方法与上述结构类似,详见完整源文件)
// ============================================================
// 工具方法
// ============================================================
getAnimPhaseText(): string {
if (this.animPhase === 'hidden') return '🛑 隐藏';
if (this.animPhase === 'entering') return '⏳ 入场中';
if (this.animPhase === 'shown') return '🟢 已显示';
if (this.animPhase === 'exiting') return '⏳ 退场中';
return this.animPhase;
}
getAnimPhaseColor(): string {
if (this.animPhase === 'hidden') return '#E74C3C';
if (this.animPhase === 'entering') return '#F39C12';
if (this.animPhase === 'shown') return '#27AE60';
if (this.animPhase === 'exiting') return '#F39C12';
return '#888888';
}
}
4.4 onChange 回调的完整数据结构
当用户拖拽面板或面板模式发生变化时,onChange 回调以 (width: number, height: number, mode: PanelMode) 的形式返回三个参数:
- width(number):面板当前的宽度,单位为 vp(虚拟像素)。对于全宽度的底部面板,这个值通常等于屏幕宽度。但在某些可拖拽侧边面板场景中,宽度会随着拖拽动态变化。
- height(number):面板当前的高度,单位为 vp。这是最常被监听的值——用户拖拽时该值连续变化,可以用来驱动面板内部内容的动画或懒加载。
- mode(PanelMode):面板当前的模式枚举值。这个参数在面板跨越模式临界点时更新——例如从 Half 模式拖拽到 Full 模式时触发。
4.5 onChange 的触发场景
onChange 会在以下三种情况下被触发:
场景一:用户拖拽 dragBar
这是最常见的情况。当用户用手指按住面板顶部的 dragBar 并向上/向下拖拽时,onChange 会连续触发,每次触发都上报最新的 width 和 height。这意味着 onChange 不是在拖拽结束时才触发一次,而是在整个拖拽过程中高频触发——类似于前端开发中的 mousemove 事件。
在 PanelOnChangeDemo 中,你可以看到宽/高数值随着拖拽不停跳变,这就是 onChange 连续触发的直接证据。
场景二:代码设置面板模式
当通过 this.currentMode = PanelMode.Full 这样的代码改变面板模式时,onChange 也会触发。这种情况下 width/height 不再是连续变化,而是直接跳转到目标模式对应的数值。在 PanelOnChangeDemo 中,点击顶部的 “➡ Full” 按钮就会触发这种跳跃式的 onChange。
场景三:面板显示/隐藏时
当面板从隐藏状态变为显示时(this.panelShow 从 false 变为 true),onChange 在面板稳定后触发一次,上报初始状态的 width、height 和 mode。同样,面板关闭时也会触发一次。
4.6 onChangeCount 的作用
在 PanelOnChangeDemo 的监控区中,我们可以看到一个"onChange 触发次数"的卡片。这个数字的用途在于:
- 验证回调的触发频率:如果你轻轻拖拽一次 dragBar,这个数字可能会增加 3-5 次,说明 onChange 是高频触发而非仅在终点触发。
- 排查回调问题:如果多次操作该数字不增加,说明 onChange 没有正确绑定或面板组件存在异常。
5. ArkTS 声明式语法核心要点
5.1 @Entry 和 @Component 装饰器
@Entry
@Component
struct PanelOnChangeDemo {
// ...
}
- @Entry:标记当前组件为页面的入口组件,一个页面有且只能有一个 @Entry。它决定了页面加载时首先渲染哪个组件。
- @Component:标记一个 struct 为 ArkUI 组件。被 @Component 装饰的 struct 必须实现
build()方法,用于声明 UI 结构。
5.2 链式属性调用
ArkTS 最显著的特征之一就是链式属性调用。每个组件构造完成后,后续通过 .属性名() 方法链式设置属性:
Text('标题')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
每一个属性方法都返回组件本身(this),因此可以无限链接。这种语法与 Flutter、SwiftUI 的链式调用风格相似,但更接近 Kotlin 的 DSL 风格。
5.3 条件渲染
if (this.panelMode === PanelMode.Mini) {
this.buildMiniContent()
} else if (this.panelMode === PanelMode.Half) {
this.buildHalfContent()
} else if (this.panelMode === PanelMode.Full) {
this.buildFullContent()
}
ArkTS 支持在 build() 方法中使用标准的 if/else if/else 条件语句进行条件渲染。这与 JavaScript/TypeScript 中的 JSX 条件渲染类似,但使用的是标准流程控制语句而非三元表达式。
5.4 ForEach 列表渲染
ForEach(this.changeLog, (log: string, index: number) => {
// 为每条日志创建一个 UI 行
})
ForEach 是 ArkTS 提供的列表渲染控制语句,接收一个数组和一个生成 UI 的回调函数。第二个参数 index 是当前项在数组中的索引。
6. @State 响应式状态管理详解
6.1 什么是 @State
@State 是 ArkTS 中用于声明响应式状态变量的装饰器。被 @State 装饰的变量发生变化时,依赖该变量的 UI 会自动重新渲染。
@State panelShow: boolean = false;
@State currentMode: PanelMode = PanelMode.Half;
@State panelWidth: number = 0;
@State panelHeight: number = 0;
@State panelModeName: string = '--';
@State onChangeCount: number = 0;
@State animPhase: string = 'hidden';
@State changeLog: string[] = [];
6.2 响应式数据的流动路径
以 panelWidth 为例,完整的响应式数据流如下:
用户拖拽面板 dragBar
↓
Panel 组件触发 onChange(width, height, mode)
↓
onChange 回调内执行:this.panelWidth = width;
↓
@State 检测到 panelWidth 值变化
↓
框架重新渲染所有依赖 panelWidth 的 UI
↓
监控区的 "宽度: xxx vp" 卡片自动更新数值
Panel 内部 Half/Full 模式的信息行自动刷新
这是一条完整的"单向数据流"链路:用户操作 → 回调 → 状态更新 → UI 重新渲染。开发者只需要负责在回调中更新 @State 变量,框架自动完成 UI 同步。
6.3 @State 的注意事项
规则一:@State 变量必须在声明时初始化。
// ✓ 正确
@State count: number = 0;
// ✗ 错误:@State 变量不能在 constructor 中初始化
规则二:@State 变量只能被所属组件自身修改。
// ✓ 正确
this.panelWidth = 320;
// ✗ 错误:不能在子组件中直接修改父组件的 @State 变量
// (应通过回调函数或 @Link / @Prop 传递)
规则三:不可变数据类型需要重新赋值才能触发更新。
对于 string、number、boolean 等基本类型和 string[] 这样的不可变对象,必须通过 = 赋值来触发更新:
// ✓ 正确:重新赋值触发更新
this.changeLog = [newEntry, ...this.changeLog].slice(0, 8);
// ✗ 错误:数组方法不触发渲染
this.changeLog.push(newEntry); // 不会触发 UI 更新
这是因为 ArkTS 的变更检测机制依赖于引用变化,而不是可变数据的属性变化。在 PanelOnChangeDemo 中,我们使用 [logEntry, ...this.changeLog].slice(0, 8) 来创建一个新数组并赋值,确保 UI 正确响应变化。
7. @Builder 装饰器——组件复用的最佳实践
7.1 基本用法
@Builder 是 ArkTS 提供的自定义构建函数装饰器,用于将重复使用的 UI 代码片段封装成可复用的方法:
@Builder
buildStatusCard(label: string, value: string, color: string): void {
Column() {
Text(label).fontSize(11).fontColor('#8899AA').margin({ bottom: 4 })
Text(value).fontSize(15).fontWeight(FontWeight.Bold).fontColor(color)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 12, bottom: 12 }).margin(3)
.backgroundColor('#16213E').borderRadius(8)
}
调用时只需一行:
this.buildStatusCard('宽度', this.panelWidth.toFixed(0) + ' vp', '#3498DB')
7.2 @Builder 的优势
- 代码复用:在 PanelOnChangeDemo 中,6 张监控卡片全部复用同一个
buildStatusCard方法 - 参数化:通过方法参数实现不同的标签、数值和颜色
- 类型安全:参数有明确的类型声明,编译时检查
- 可嵌套:@Builder 方法内部可以调用其他 @Builder 方法
7.3 @Builder 与普通方法的区别
| 维度 | @Builder 方法 | 普通方法 |
|---|---|---|
| 返回值 | 必须显式声明 void |
任意类型 |
| 调用方式 | this.buildXXX() |
this.method() |
| 访问 @State | 可以直接访问 | 可以直接访问 |
| 参数传递 | 支持按值传递 | 支持按值/引用传递 |
| 条件渲染 | 内部支持 if/ForEach | 内部支持 if/ForEach |
| 链式属性 | 调用后不可再链式 | 无限制 |
8. onChange 回调机制深度剖析
8.1 回调签名
.onChange((width: number, height: number, mode: PanelMode) => {
// 面板状态变化时的处理逻辑
})
onChange 回调接收三个参数:
| 参数 | 类型 | 说明 | 典型值 |
|---|---|---|---|
| width | number | 面板当前宽度(vp) | 360(全宽屏) |
| height | number | 面板当前高度(vp) | 300(Half)~ 700(Full) |
| mode | PanelMode | 面板当前模式枚举 | Mini / Half / Full |
8.2 回调触发的精确时机
通过 PanelOnChangeDemo 的日志记录功能,我们可以观察到三种回调触发模式:
连续触发(用户拖拽时):
[23:13:45] 宽=360 高=342 模式=Half(半屏)
[23:13:45] 宽=360 高=356 模式=Half(半屏)
[23:13:45] 宽=360 高=421 模式=Half(半屏)
[23:13:45] 宽=360 高=522 模式=Half(半屏)
[23:13:45] 宽=360 高=644 模式=Full(全屏)
注意以上五条日志几乎在同一秒生成,width 保持不变(全宽面板),height 逐渐增大,mode 在跨越 50% 高度时从 Half 变为 Full。
单次触发(代码切换时):
[23:14:02] 宽=360 高=400 模式=Half(半屏)
点击 “➡ Full” 按钮后高度直接跳到 Full 模式对应的值,中间没有连续值过渡。
双向同步的验证:
onChange 回调内的 this.currentMode = mode 实现了面板状态的双向同步——用户更改面板状态 → onChange 上报 → @State 变量更新 → 面板内容根据模式变化重新渲染。这就形成了一个闭环。
8.3 一个常见的误区
很多初学者会认为 onChange 只在拖拽结束时触发一次。实际上,onChange 在拖拽过程中是持续触发的。在 PanelOnChangeDemo 中,你只需快速拖拽一次 dragBar,count 卡片上的数字就会增加 5-10 次,这证明 onChange 的触发频率相当高。
如果你的场景只需要知道面板最终停在什么位置(而不需要中间过程),建议在 onChange 中加入防抖逻辑:
private onChangeTimer: number = -1;
.onChange((width, height, mode) => {
clearTimeout(this.onChangeTimer);
this.onChangeTimer = setTimeout(() => {
// 面板停止变化后 300ms 才执行
this.handlePanelStable(width, height, mode);
}, 300);
})
9. 生命周期管理——onAppear 与 onDisAppear
9.1 onAppear
onAppear 在面板入场动画开始时触发。此时面板即将从屏幕底部滑入视口。
.onAppear(() => {
this.animPhase = 'entering'; // 进入 "入场中" 阶段
clearTimeout(this.animTimerId);
this.animTimerId = setTimeout(() => {
this.animPhase = 'shown'; // 400ms 后变为 "已显示"
}, 400);
})
9.2 onDisAppear
onDisAppear 在面板退场动画结束时触发。此时面板已经完全离开视口。
.onDisAppear(() => {
this.animPhase = 'hidden'; // 进入 "隐藏" 阶段
clearTimeout(this.animTimerId); // 清理计时器,防止内存泄漏
})
9.3 完整的生命周期阶段
通过将 onAppear / onDisAppear 与 setTimeout 结合,我们可以模拟出 4 个精细的动画阶段:
| 阶段 | animPhase 值 | 触发时机 | 监控卡片颜色 |
|---|---|---|---|
| 隐藏 | hidden | 初始状态 / onDisAppear 后 | 红色 #E74C3C |
| 入场中 | entering | onAppear 触发时 | 橙色 #F39C12 |
| 已显示 | shown | onAppear 后 400ms | 绿色 #27AE60 |
| 退场中 | exiting | 面板关闭时 | 橙色 #F39C12 |
9.4 生命周期的重要性
监听面板的生命周期在实际项目中有着重要的应用价值:
- 数据懒加载:在 onAppear 中延迟加载面板内的数据,避免页面启动时加载所有面板数据
- 资源释放:在 onDisAppear 中停止面板内的视频播放、动画循环等资源占用操作
- 埋点统计:记录用户打开/关闭面板的行为用于数据分析
- 状态恢复:面板关闭时保存用户的滚动位置,再次打开时恢复
10. 从实战看 ArkTS 严格模式下常见的编译错误
在开发这两个示例应用的过程中,我们遇到了几个典型的 ArkTS 编译错误。记录并分析这些错误,可以帮助更多开发者避坑。
10.1 错误一:Property 'showMode' does not exist
ArkTS Compiler Error
Error Message: Property 'showMode' does not exist on type 'PanelAttribute'.
原因:在 HarmonyOS SDK 6.1.1 (API 24) 中,Panel 组件的属性名从 showMode 变更为 mode。
修复:将 .showMode(this.panelMode) 改为 .mode(this.panelMode)。
教训:不同 SDK 版本的 API 可能有细微差异,开发前应当查看当前 SDK 的 API 文档或通过构建反馈来验证。
10.2 错误二:Argument of type 'PanelMode' is not assignable to parameter of type 'boolean'
Error Message: Argument of type 'PanelMode' is not assignable to parameter of type 'boolean'.
原因:Panel 构造函数的参数在新版 SDK 中从 Panel(show: PanelMode) 变更为 Panel(show: boolean)
修复:将 Panel(this.panelMode) 改为 Panel(this.showPanel),面板模式通过 .mode() 属性单独控制。
10.3 错误三:Property 'fullScreen' does not exist on type 'PanelAttribute'
原因:早期版本的 Panel 支持 .fullScreen() 属性,但在 SDK 6.1.1 中这个属性已被移除。面板类型通过 .type(PanelType.Foldable) 来控制。
修复:移除 .fullScreen(),改用 .type() + .mode() 的组合来控制面板的模式切换。
10.4 错误四:Property 'onDisappear' does not exist. Did you mean 'onDisAppear'?
Error Message: Property 'onDisappear' does not exist on type 'PanelAttribute'.
Did you mean 'onDisAppear'?
原因:ArkTS 的生命周期回调方法采用驼峰命名法,其中 disappear 中的 App 首字母大写,正确的名称为 onDisAppear。
修复:将 .onDisappear() 改为 .onDisAppear()。
教训:ArkTS 的生命周期方法命名并非简单的英文单词拼接,而是遵循严格的驼峰规则。类似的还有 onAppear(注意不是 onApper)。
10.5 错误五:Object literal must correspond to some explicitly declared class or interface
Error Message: Object literal must correspond to some explicitly declared class or interface
(arkts-no-untyped-obj-literals)
原因:ArkTS 严格模式禁止使用无类型声明的对象字面量。
// 以下代码在 ArkTS 严格模式下报错:
const map: Record<string, string> = {
key1: 'value1',
key2: 'value2',
};
修复:改用条件语句替代对象映射:
getAnimPhaseText(): string {
if (this.animPhase === 'hidden') return '🛑 隐藏';
if (this.animPhase === 'entering') return '⏳ 入场中';
if (this.animPhase === 'shown') return '🟢 已显示';
if (this.animPhase === 'exiting') return '⏳ 退场中';
return this.animPhase;
}
教训:ArkTS 严格模式对类型安全性有更高的要求。虽然 JavaScript/TypeScript 中对象字面量随处可见,但在 ArkTS 中需要通过接口或类明确声明类型。
10.6 错误汇总表
| 错误信息 | 原因 | 修复方式 |
|---|---|---|
showMode does not exist |
API 属性名变更 | .showMode() → .mode() |
PanelMode not assignable to boolean |
构造参数类型变更 | Panel(mode) → Panel(boolean) |
fullScreen does not exist |
属性被移除 | 改用 .type(PanelType.Foldable) |
onDisappear does not exist |
命名大小写错误 | .onDisappear() → .onDisAppear() |
untyped object literals |
严格模式禁用无类型字面量 | 改用 if/else 或显式接口 |
这些错误的共同本质是:开发环境中的 SDK 版本与 API 文档/示例代码使用的版本不一致。从 API 7 到 API 24,Panel 组件经历了多次 API 调整。在开发中始终使用当前环境的构建反馈来验证代码,比盲目相信网络上的示例更可靠。
11. hvigor 构建工具的使用与验证
11.1 hvigor 简介
hvigor 是 HarmonyOS 项目的构建工具,类似于 Android 项目的 Gradle。项目根目录下的 hvigorw(hvigor wrapper)脚本负责下载和管理 hvigor 的版本。
11.2 常用命令
# 查看版本
hvigorw --version # 输出:6.24.2
# 查看所有可用任务
hvigorw tasks
# 组装应用(完整构建)
hvigorw assembleApp
# 查看帮助
hvigorw --help
11.3 快速构建验证
在开发过程中,可以使用以下命令进行快速的编译验证:
hvigorw assembleApp --no-daemon --no-parallel --no-incremental
参数说明:
--no-daemon:不使用守护进程,避免缓存干扰--no-parallel:不并行执行任务,方便定位问题--no-incremental:不增量构建,确保完整验证
完整的构建包括 30+ 个步骤,从 PreBuildApp、CompileResource、CompileArkTS 到 PackageHap、SignHap、PackageApp。其中最关键的是 CompileArkTS 步骤,所有 ArkTS 语法错误都在这一步暴露。
11.4 构建输出解读
构建成功时末行为:
> hvigor BUILD SUCCESSFUL in 6 s 828 ms
构建失败时会输出具体的错误信息。通过逐次构建和修复,PanelOnChangeDemo 从最初的 4 个 ERROR 到最后的 0 个 ERROR,经历了 5 次构建验证。
11.5 构建日志分析
构建过程中,warn 记录以 WARN: 开头,error 以 ERROR: 开头。例如 SDK 中的 deprecation 告警:
WARN: 'Panel' has been deprecated.
WARN: 'PanelMode' has been deprecated.
WARN: 'showToast' has been deprecated.
这些告警说明 Panel 及相关 API 从 API 12 开始被标记为弃用,官方推荐使用 bindSheet 属性替代。但在 API 24 中 Panel 仍然可以正常使用,只是可能在未来的版本中被移除。
12. 性能优化与最佳实践
12.1 避免不必要的 onChange 操作
onChange 在拖拽过程中高频触发,回调内的操作越轻量越好。避免在 onChange 中执行:
- 网络请求
- 大量数组遍历
- 复杂数学计算
- 文件 IO 操作
如果需要根据面板位置加载数据,应该在 onChange 中加入防抖或节流:
// 防抖:只在面板停止变化后执行
private debounceTimer: number = -1;
.onChange((width, height, mode) => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.loadDataForPanelState(mode);
}, 300);
})
12.2 @State 变量的合理使用
- 只有需要驱动 UI 变化的变量才使用 @State 装饰
- 不需要驱动 UI 的临时变量用普通属性(
private) - @State 变量变化会触发整个组件的重新渲染,频繁变化的变量要谨慎
12.3 @Builder 与普通方法的选择
- 当构建的 UI 片段超过 10 行时,应封装为 @Builder 方法
- 当同一 UI 片段在不同位置被复用 3 次以上时,应封装为 @Builder 方法
- 纯逻辑处理(数据计算、格式化等)不应放在 @Builder 中,而应放在普通方法中
12.4 数组操作的性能考虑
// 方式一(不推荐):每次 onChange 都创建新数组(O(n) 复杂度)
this.changeLog = [newEntry, ...this.changeLog].slice(0, 8);
// 方式二(推荐):固定大小的循环队列
// 适合日志数量很大时的性能优化
虽然对于 8 条日志来说,方式一已经足够高效,但如果日志数量达到数百甚至数千条,就需要考虑使用固定大小的数组或链表来避免频繁的数组复制操作。
12.5 计时器的正确清理
在使用 setTimeout 或 setInterval 时,务必在组件销毁时清理计时器:
private animTimerId: number = -1;
// 设置计时器时,先清理旧的
clearTimeout(this.animTimerId);
this.animTimerId = setTimeout(() => {
// ...
}, 400);
// 组件销毁时清理
.onDisAppear(() => {
clearTimeout(this.animTimerId);
})
如果计时器未被清理,在面板反复打开和关闭后,可能会导致以下问题:
- 计时器回调访问已被销毁的 @State 变量,引发运行时错误
- 多个计时器叠加执行,产生不符合预期的 UI 状态变化
- 内存泄漏(虽然 ArkTS 有垃圾回收机制,但不必要的引用仍会延迟回收)
13. 总结与扩展思考
13.1 本文总结
通过两个完整的示例应用,我们系统学习了:
- Panel 组件的基础用法:构造参数、属性链配置、三种模式切换
- onChange 回调机制:三个参数的含义、三种触发场景、高频触发的特点
- @State 响应式状态管理:单向数据流动路径、数组更新的注意事项
- @Builder 构建函数:代码复用的最佳实践、与普通方法的区别
- 生命周期管理:onAppear 和 onDisAppear 的触发时机与使用场景
- 构建验证:hvigor 工具的使用、常见编译错误的修复
- 严格模式约束:ArkTS 与 TypeScript 的差异、对象字面量的限制
13.2 Panel 与 bindSheet 的未来方向
从 API 12 开始,Panel 组件被标记为弃用,官方推荐使用 bindSheet 属性作为替代方案。bindSheet 是一个通用属性,可以绑定到任何组件上,提供更灵活的面板交互方式。
// bindSheet 的示例用法(API 12+)
Column() {
Button('打开面板')
.bindSheet($$this.isSheetPresent, {
builder: this.mySheetBuilder.bind(this),
mode: SheetMode.HALF,
dragBar: true,
})
}
然而,在 API 24 中 Panel 仍然可以正常使用。对于需要兼容旧版本 SDK 的项目,Panel 仍然是可行的选择。
13.3 扩展应用场景
Panel + onChange 的模式可以应用于多种实际场景:
- 视频播放器:底部面板展示播放列表,onChange 监听面板展开程度来控制视频窗口的缩放
- 地图应用:底部面板展示路线信息,onChange 反馈面板高度来调整地图的可视区域
- 电商应用:底部面板展示商品规格选择,onChange 触达到达目标高度时自动加载更多SKU数据
- IM 应用:底部面板展示表情包/附件选择,onChange 联动输入框的 safe-area 调整
13.4 项目文件的完整结构
整个示例项目的文件结构如下:
MyApplication/
├── entry/
│ └── src/main/ets/
│ ├── entryability/
│ │ └── EntryAbility.ets # 应用入口,指向 PanelOnChangeDemo
│ └── pages/
│ ├── Index.ets # 原始主页
│ ├── PanelDemo.ets # 基础面板演示(449行)
│ └── PanelOnChangeDemo.ets # onChange 监听演示(648行)
├── entry/src/main/resources/base/profile/
│ └── main_pages.json # 页面路由注册
├── build-profile.json5 # 项目构建配置
└── hvigorw # Hvigor 构建工具 wrapper
三个关键的配置文件:
EntryAbility.ets — 启动时加载的页面:
windowStage.loadContent('pages/PanelOnChangeDemo', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'App', 'Failed: ' + JSON.stringify(err));
}
});
main_pages.json — 注册所有页面路由:
{
"src": [
"pages/Index",
"pages/PanelDemo",
"pages/PanelOnChangeDemo"
]
}
build-profile.json5 — SDK 版本配置:
{
"app": {
"products": [
{
"name": "default",
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}
]
}
}
更多推荐


所有评论(0)