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+ 个步骤,从 PreBuildAppCompileResourceCompileArkTSPackageHapSignHapPackageApp。其中最关键的是 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 计时器的正确清理

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

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"
      }
    ]
  }
}