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 枚举

typescript 复制代码
enum PanelMode {
  Mini = 0,   // 最小化模式,通常仅显示拖拽条+一行文字
  Half = 1,   // 半屏模式,屏幕的一半高度
  Full = 2,   // 全屏模式,几乎覆盖整个屏幕(留出状态栏空间)
}

2.4 PanelType 枚举

typescript 复制代码
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 完整代码

typescript 复制代码
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 的核心关注点不是"面板里装什么内容",而是**"面板的状态变化如何被监听和展示"**。因此,我们设计了三个层次的信息展示区:

  1. 控制区(Control Section):打开/关闭面板、一键切换到 Half/Full 模式
  2. 监控区(Monitor Section):6 张状态卡片,实时刷新 onChange 上报的数据
  3. 日志区(Log Section):最近 8 次 onChange 触发的详细记录,含时间戳

这三个区域全部位于主内容区(位于 Panel 之上),而 Panel 内部则展示对应模式下的内容作为交互反馈。

4.3 完整代码

typescript 复制代码
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 装饰器

typescript 复制代码
@Entry
@Component
struct PanelOnChangeDemo {
  // ...
}
  • @Entry:标记当前组件为页面的入口组件,一个页面有且只能有一个 @Entry。它决定了页面加载时首先渲染哪个组件。
  • @Component :标记一个 struct 为 ArkUI 组件。被 @Component 装饰的 struct 必须实现 build() 方法,用于声明 UI 结构。

5.2 链式属性调用

ArkTS 最显著的特征之一就是链式属性调用 。每个组件构造完成后,后续通过 .属性名() 方法链式设置属性:

typescript 复制代码
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 列表渲染

typescript 复制代码
ForEach(this.changeLog, (log: string, index: number) => {
  // 为每条日志创建一个 UI 行
})

ForEach 是 ArkTS 提供的列表渲染控制语句,接收一个数组和一个生成 UI 的回调函数。第二个参数 index 是当前项在数组中的索引。


6. @State 响应式状态管理详解

6.1 什么是 @State

@State 是 ArkTS 中用于声明响应式状态变量的装饰器。被 @State 装饰的变量发生变化时,依赖该变量的 UI 会自动重新渲染。

typescript 复制代码
@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 变量必须在声明时初始化。

typescript 复制代码
// ✓ 正确
@State count: number = 0;

// ✗ 错误:@State 变量不能在 constructor 中初始化

规则二:@State 变量只能被所属组件自身修改。

typescript 复制代码
// ✓ 正确
this.panelWidth = 320;

// ✗ 错误:不能在子组件中直接修改父组件的 @State 变量
// (应通过回调函数或 @Link / @Prop 传递)

规则三:不可变数据类型需要重新赋值才能触发更新。

对于 string、number、boolean 等基本类型和 string\[\] 这样的不可变对象,必须通过 = 赋值来触发更新:

typescript 复制代码
// ✓ 正确:重新赋值触发更新
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 代码片段封装成可复用的方法:

typescript 复制代码
@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)
}

调用时只需一行:

typescript 复制代码
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 回调签名

typescript 复制代码
.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 中加入防抖逻辑:

typescript 复制代码
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 在面板入场动画开始时触发。此时面板即将从屏幕底部滑入视口。

typescript 复制代码
.onAppear(() => {
  this.animPhase = 'entering';    // 进入 "入场中" 阶段
  clearTimeout(this.animTimerId);
  this.animTimerId = setTimeout(() => {
    this.animPhase = 'shown';     // 400ms 后变为 "已显示"
  }, 400);
})

9.2 onDisAppear

onDisAppear 在面板退场动画结束时触发。此时面板已经完全离开视口。

typescript 复制代码
.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 严格模式禁止使用无类型声明的对象字面量。

typescript 复制代码
// 以下代码在 ArkTS 严格模式下报错:
const map: Record<string, string> = {
  key1: 'value1',
  key2: 'value2',
};

修复:改用条件语句替代对象映射:

typescript 复制代码
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 常用命令

bash 复制代码
# 查看版本
hvigorw --version                # 输出:6.24.2

# 查看所有可用任务
hvigorw tasks

# 组装应用(完整构建)
hvigorw assembleApp

# 查看帮助
hvigorw --help

11.3 快速构建验证

在开发过程中,可以使用以下命令进行快速的编译验证:

bash 复制代码
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 中加入防抖或节流:

typescript 复制代码
// 防抖:只在面板停止变化后执行
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 数组操作的性能考虑

typescript 复制代码
// 方式一(不推荐):每次 onChange 都创建新数组(O(n) 复杂度)
this.changeLog = [newEntry, ...this.changeLog].slice(0, 8);

// 方式二(推荐):固定大小的循环队列
// 适合日志数量很大时的性能优化

虽然对于 8 条日志来说,方式一已经足够高效,但如果日志数量达到数百甚至数千条,就需要考虑使用固定大小的数组或链表来避免频繁的数组复制操作。

12.5 计时器的正确清理

在使用 setTimeout 或 setInterval 时,务必在组件销毁时清理计时器:

typescript 复制代码
private animTimerId: number = -1;

// 设置计时器时,先清理旧的
clearTimeout(this.animTimerId);
this.animTimerId = setTimeout(() => {
  // ...
}, 400);

// 组件销毁时清理
.onDisAppear(() => {
  clearTimeout(this.animTimerId);
})

如果计时器未被清理,在面板反复打开和关闭后,可能会导致以下问题:

  • 计时器回调访问已被销毁的 @State 变量,引发运行时错误
  • 多个计时器叠加执行,产生不符合预期的 UI 状态变化
  • 内存泄漏(虽然 ArkTS 有垃圾回收机制,但不必要的引用仍会延迟回收)

13. 总结与扩展思考

13.1 本文总结

通过两个完整的示例应用,我们系统学习了:

  1. Panel 组件的基础用法:构造参数、属性链配置、三种模式切换
  2. onChange 回调机制:三个参数的含义、三种触发场景、高频触发的特点
  3. @State 响应式状态管理:单向数据流动路径、数组更新的注意事项
  4. @Builder 构建函数:代码复用的最佳实践、与普通方法的区别
  5. 生命周期管理:onAppear 和 onDisAppear 的触发时机与使用场景
  6. 构建验证:hvigor 工具的使用、常见编译错误的修复
  7. 严格模式约束:ArkTS 与 TypeScript 的差异、对象字面量的限制

13.2 Panel 与 bindSheet 的未来方向

从 API 12 开始,Panel 组件被标记为弃用,官方推荐使用 bindSheet 属性作为替代方案。bindSheet 是一个通用属性,可以绑定到任何组件上,提供更灵活的面板交互方式。

typescript 复制代码
// 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 --- 启动时加载的页面:

typescript 复制代码
windowStage.loadContent('pages/PanelOnChangeDemo', (err) => {
  if (err.code) {
    hilog.error(DOMAIN, 'App', 'Failed: ' + JSON.stringify(err));
  }
});

main_pages.json --- 注册所有页面路由:

json 复制代码
{
  "src": [
    "pages/Index",
    "pages/PanelDemo",
    "pages/PanelOnChangeDemo"
  ]
}

build-profile.json5 --- SDK 版本配置:

json5 复制代码
{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.1(24)",
        "runtimeOS": "HarmonyOS"
      }
    ]
  }
}