Panel 滑动面板完全指南:半屏/全屏切换、拖拽交互与地图应用

本文基于 HarmonyOS(ArkTS 声明式开发范式,API 12 / 5.0.0)写作,所有示例均可在 DevEco Studio 模拟器中验证。配套演示工程位于本文同级目录 ohos/,包含完整可运行的 EntryAbility.etsIndex.ets


一、引言

打开地图 App 找一家咖啡馆:地图占满屏幕,屏幕底部浮着一块白色面板,半屏高度,列着四家"附近商户"。你上滑,面板展开成全屏;下滑,面板缩成一条"向上滑动查看更多"的横条;再拖,它又弹回半屏。整个过程手指没有离开过屏幕底部三分之一,地图始终在背景里保持可见——这就是 Panel 滑动面板的经典价值。

滑动面板解决的是一对矛盾:主内容要"看得全",附加内容要"拿得到"。地图是主内容,商家列表是附加内容;全屏看地图会丢列表,全屏看列表会丢地图。Panel 用"可停靠的三档高度"化解了这个矛盾——Mini 一档只露提示,Half 一档兼顾两者,Full 一档专注详情,用户按需拖拽,页面不跳转、层级不打断。详情展开、播放器控制台、筛选器、购物车,凡是"从底部弹出来又不想全屏遮死"的内容,都是它的舞台。

ArkUI 的 Panel 从 API 8 起提供,能力围绕四个关键词:mode(Mini/Half/Full 三档高度)、type(FOLDBAR 占位 / TEMPORARY 悬浮)、dragBar(拖拽手柄)、onChange(模式变化回调)。它既是组件也是容器——内容由子组件自由填充,三档高度下可以展示完全不同的布局。真正考验设计的地方在于两处:内容如何随模式切换,以及面板内的滚动与页面的滚动如何互不打架

本文的路线是:先讲透 Panel 的构造与三档模式,对比两种类型的占位差异,再用示意图说明"手势归属"的滚动冲突解法,接着给出完整演示工程,覆盖地图底部面板、模式控制与类型对比、滚动冲突处理三个场景,最后谈模拟器验证、常见问题与滑动面板交互设计原则。


二、环境准备

Panel 属于 ArkUI 基础组件,API 8 起可用。本文用到的能力(三模式、两类型、onChangedragBar)在 API 12 上全部稳定,推荐 API 12 及以上环境。

项目 推荐配置 说明
DevEco Studio 5.0 及以上 需支持 API 12 的 SDK
HarmonyOS SDK 5.0.0(12) compatibleSdkVersion 与之对应
设备 Phone 模拟器或真机 本文以模拟器验证为主(拖拽手感真机更佳)
工程类型 Stage 模型 + ArkTS EntryAbility 继承 UIAbility

工程落地路径与前几篇一致,两种方式任选:

  • 方式一:在 DevEco Studio 新建 Empty Ability 工程,直接写 ArkTS 原生页面。本文演示工程即采用这种方式。
  • 方式二:在已有的 Flutter·鸿蒙壳工程里,把 ohos/entry/src/main/ets/ 下的页面与组件放进原生工程。这种方式下 EntryAbility 通常继承自 FlutterAbility,演示组件的代码不受影响。

本文配套工程目录结构如下(关键文件已给出):

ohos/
├── AppScope/app.json5
├── build-profile.json5
└── entry/src/main/
    ├── module.json5
    └── ets/
        ├── entryability/EntryAbility.ets
        ├── pages/Index.ets
        ├── model/ShopModel.ets
        └── components/*.ets

若你用的是方式二(Flutter 壳),只需关注 pages/Index.etsmodel/ShopModel.etscomponents/ 下的组件代码,其余配置沿用原工程即可。


三、核心 API 与原理解析

3.1 构造:show、type、mode 与 dragBar

Panel 的构造参数决定了它的"身份":

Panel({
  show: true,                    // 是否显示面板
  type: PanelType.FOLDBAR,       // 面板类型:占位 / 悬浮
  mode: PanelMode.HALF,          // 初始高度档位
  dragBar: true                  // 是否显示顶部拖拽横条
}) {
  // 面板内容,任意子组件
}
参数 类型 默认值 作用
show boolean false 面板是否显示
type PanelType FOLDBAR 占位折叠 / 临时悬浮
mode PanelMode HALF Mini/Half/Full 三档
dragBar boolean true 顶部拖拽手柄显隐

dragBar 只是隐藏那根横条,面板依然可拖拽——手柄是视觉提示,不是手势入口。show 控制整个面板的存在,适合与"展开详情"按钮联动。

3.2 三档模式:Mini、Half、Full

PanelMode 定义了面板的三个"停靠点":

模式 高度语义 典型内容
MINI 底部一条 提示横条:“上滑查看更多”
HALF 约半屏 列表摘要:附近商家、播放控制台
FULL 近全屏 完整内容:搜索 + 全列表、详情页

经验高度比(可结合 fullHeight/halfHeight/miniHeight 定制):

[
h_{\text{mini}} \approx 8%\sim 12% \cdot H_{\text{屏}}, \qquad
h_{\text{half}} \approx 40%\sim 50% \cdot H_{\text{屏}}
]

三档不是三个独立页面,而是同一容器的三个"取景框"——内容应随档位增删:Mini 只放一句提示,Half 放列表前几项,Full 放搜索框加完整列表。演示工程的地图面板正是这样设计的(见 4.4)。

3.3 两种类型:FOLDBAR 与 TEMPORARY

type 决定面板与页面布局的关系,这是最容易忽略的分水岭:

类型 布局行为 适用场景
FOLDBAR 收起时仍占据底部空间,内容被顶起 地图底部面板、常驻控制台
TEMPORARY 悬浮于页面之上,不占位 轻量浮层、临时操作条

同样是"收成一条",FOLDBAR 会把页面内容往上推(页面为了给面板让位而重新排布),TEMPORARY 则像浮层一样盖在内容上面。演示工程用"背景卡片"验证这一点:切到 TEMPORARY,背景卡片保持原位,面板悬浮其上;切回 FOLDBAR,卡片被顶起。

3.4 受控模式:mode 与 onChange 的双向绑定

面板被拖拽时,模式如何被业务感知?答案是 onChange

Panel({ show: true, type: PanelType.FOLDBAR, mode: PanelMode.HALF, dragBar: true }) {
  // 内容
}
.mode(this.currentMode)          // 受控:状态决定档位
.onChange((isVisible: boolean, currentMode: PanelMode) => {
  this.currentMode = currentMode; // 拖拽结果回写状态
})

onChange 的回调参数有两个:isVisible(显示状态)与 currentMode(当前档位)。受控模式是必须的——只有把 mode 绑到 @State、再把 onChange 的结果回写,才能实现两件事:内容随档位切换布局、按钮直接跳到指定档位。这套"状态驱动档位"的模式,在 4.4 与 4.5 中都有落地。

3.5 手势与滚动:谁的手指归谁

面板的交互核心是拖拽,但页面往往还有自己的滚动内容——地图页要平移地图,列表页要滚文章。面板与页面并存时,滚动冲突的判定逻辑是:

手指触摸屏幕

触摸点在面板范围内?

面板内是否有可滚动区域?

滚动面板内内容

拖拽面板切换档位

滚动页面本身

两条铁律:面板内的滚动只发生在面板内(面板内容如 List/Scroll 自带滚动容器,触摸在面板上绝不会带动页面);面板外的滚动只发生在页面。唯一要小心的是"面板刚好遮住页面滚动区"的误触——那是布局问题而非手势问题,把面板档位设计得矮一点即可。演示工程的第三个 Tab 专门做了对照实验(见 4.6)。

3.6 与相关组件的选型对比

组件 形态 适用场景
Panel 底部三档面板 详情展开、控制面板、地图底部列表
bindSheet 底部弹层(可多档) 表单、筛选器,内容较重时
bindPopup/CustomDialog 弹窗 轻提示、确认框
Navigation 子页 整页 详情页等完整页面跳转

选型一句话:需要"拖拽三档 + 背景保留可见"用 Panel;选完即关的表单用 bindSheet;只弹一下的提示用弹窗


四、完整代码实现

下面给出演示工程的完整可运行代码。工程以 Tabs 组织三个模块:地图底部面板(核心场景)、模式控制与类型对比、滚动冲突处理。数据模型 ShopModel.ets 提供商家列表。

4.1 入口:EntryAbility.ets

import { UIAbility } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  private readonly TAG: string = 'PanelGuideAbility';

  onCreate(want: object, launchParam: object): void {
    hilog.info(0x0000, this.TAG, '%{public}s', 'Ability onCreate');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, this.TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(0x0000, this.TAG, '%{public}s', 'Succeeded in loading the content.');
    });
  }

  onForeground(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onForeground'); }
  onBackground(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onBackground'); }
  onDestroy(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onDestroy'); }
  onWindowStageDestroy(): void { hilog.info(0x0000, this.TAG, '%{public}s', 'onWindowStageDestroy'); }
}

4.2 数据模型:ShopModel.ets

export class ShopItem {
  name: string;
  score: string;
  distance: string;
  tag: string;

  constructor(name: string, score: string, distance: string, tag: string) {
    this.name = name;
    this.score = score;
    this.distance = distance;
    this.tag = tag;
  }
}

export const SHOPS: ShopItem[] = [
  new ShopItem('老王牛肉面', '4.8', '350m', '面馆'),
  new ShopItem('星巴克咖啡', '4.6', '500m', '咖啡'),
  new ShopItem('永辉超市', '4.5', '800m', '超市'),
  new ShopItem('同仁堂药店', '4.7', '1.2km', '药店'),
];

4.3 主页面:Index.ets

import { MapPanelDemo } from '../components/MapPanelDemo';
import { ModeControlDemo } from '../components/ModeControlDemo';
import { ConflictDemo } from '../components/ConflictDemo';

@Entry
@Component
struct Index {
  @State currentIndex: number = 0;

  build() {
    Column() {
      Tabs({ barPosition: BarPosition.Start, index: this.currentIndex }) {
        TabContent() { MapPanelDemo() }.tabBar('地图面板')
        TabContent() { ModeControlDemo() }.tabBar('模式控制')
        TabContent() { ConflictDemo() }.tabBar('滚动冲突')
      }
      .vertical(false)
      .scrollable(true)
      .barMode(BarMode.Scrollable)
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
  }
}
```### 4.4 地图底部面板:MapPanelDemo.ets

这是本文的核心场景——模拟地图 + 底部 FOLDBAR 面板,三档内容随模式增删。

```typescript
import { promptAction } from '@kit.ArkUI';
import { ShopItem, SHOPS } from '../model/ShopModel';

@Component
export struct MapPanelDemo {
  @State currentMode: PanelMode = PanelMode.HALF;
  @State selectedShop: number = -1;

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        Text('🗺️ 模拟地图区域')
          .fontSize(24)
          .fontColor(Color.White)
          .fontWeight(FontWeight.Bold)
        Text('拖动底部面板查看附近商家')
          .fontSize(13)
          .fontColor('#E6F0FF')
        Text('📍')
          .fontSize(36)
          .margin({ top: 16 })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .linearGradient({
        direction: GradientDirection.Bottom,
        colors: [['#6BB3FF', 0.0], ['#3E7BFA', 1.0]]
      })

      Panel({
        show: true,
        type: PanelType.FOLDBAR,
        mode: PanelMode.HALF,
        dragBar: true
      }) {
        Column({ space: 0 }) {
          if (this.currentMode === PanelMode.MINI) {
            Column({ space: 8 }) {
              Text('🕐 附近 4 家商户')
                .fontSize(15)
                .fontWeight(FontWeight.Medium)
                .fontColor('#333333')
              Text('向上滑动查看更多')
                .fontSize(12)
                .fontColor('#999999')
            }
            .width('100%')
            .height(52)
            .justifyContent(FlexAlign.Center)
          } else if (this.currentMode === PanelMode.HALF) {
            Text('附近商户(半屏)')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .width('92%')
              .textAlign(TextAlign.Start)
              .margin({ top: 4, bottom: 8 })
            ForEach(SHOPS, (item: ShopItem, index: number) => {
              this.shopRow(item, index)
            }, (item: ShopItem) => item.name)
            Text('上滑全屏查看更多 · 下滑收起')
              .fontSize(12)
              .fontColor('#999999')
              .margin({ top: 8 })
          } else {
            Row({ space: 8 }) {
              Text('🔍').fontSize(16)
              Text('搜索附近商户、地点')
                .fontSize(14)
                .fontColor('#999999')
              Blank()
            }
            .width('92%')
            .height(40)
            .padding({ left: 12, right: 12 })
            .borderRadius(20)
            .backgroundColor('#F2F4F7')
            .margin({ top: 4, bottom: 8 })

            Text('全部商户(全屏)')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .width('92%')
              .textAlign(TextAlign.Start)
              .margin({ bottom: 8 })
            List({ space: 10 }) {
              ForEach(SHOPS, (item: ShopItem, index: number) => {
                ListItem() {
                  this.shopRow(item, index)
                }
              }, (item: ShopItem) => item.name)
            }
            .width('92%')
            .layoutWeight(1)
            .scrollBar(BarState.Off)

            Text('下滑收起面板 · 点击商户可选中')
              .fontSize(12)
              .fontColor('#999999')
              .margin({ top: 8, bottom: 8 })
          }
        }
        .width('100%')
        .height('100%')
      }
      .mode(this.currentMode)
      .onChange((isVisible: boolean, currentMode: PanelMode) => {
        this.currentMode = currentMode;
      })
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  shopRow(item: ShopItem, index: number) {
    Row({ space: 12 }) {
      Column({ space: 2 }) {
        Text(item.name)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#333333')
        Text(`${item.tag} · ${item.distance}`)
          .fontSize(12)
          .fontColor('#999999')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Text(`${item.score}`)
        .fontSize(13)
        .fontColor('#FA8C16')

      Text(this.selectedShop === index ? '✓ 已选中' : '查看')
        .fontSize(13)
        .fontColor(this.selectedShop === index ? '#0A59F7' : '#666666')
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .borderRadius(12)
        .backgroundColor(this.selectedShop === index ? '#EAF2FF' : '#F2F4F7')
        .onClick(() => {
          this.selectedShop = index;
          promptAction.showToast({ message: `已选中:${item.name}`, duration: 1200 });
        })
    }
    .width('100%')
    .padding(14)
    .borderRadius(12)
    .backgroundColor(Color.White)
    .border({ width: 1, color: '#F0F0F0' })
  }

  modeName(mode: PanelMode): string {
    if (mode === PanelMode.MINI) {
      return 'Mini';
    }
    if (mode === PanelMode.HALF) {
      return 'Half';
    }
    return 'Full';
  }
}

这段代码浓缩了三个工程细节:Stack 让面板永远贴着底部,地图背景随档位露出不同高度;三档内容用 currentMode 分支增删——Mini 一条提示、Half 四家商户、Full 搜索框加 ListmodeonChange 双向绑定,拖到哪档内容跟到哪档,顶部徽标同步显示当前模式。

4.5 模式控制与类型对比:ModeControlDemo.ets

三个按钮直接跳档、两个按钮切换类型,受控模式的标准示范:

Button('Mini')
  .backgroundColor(this.mode === PanelMode.MINI ? '#0A59F7' : '#F0F0F0')
  .onClick(() => {
    this.mode = PanelMode.MINI;
  })

Panel({
  show: true,
  type: this.panelType,
  mode: this.mode,
  dragBar: true
}) {
  // 内容
}
.mode(this.mode)
.onChange((isVisible: boolean, currentMode: PanelMode) => {
  this.mode = currentMode;
})

按钮高亮由 mode 状态驱动,点击即 this.mode = PanelMode.X,面板动画过渡到对应档位——这正是受控模式的价值:业务代码可以随时指定档位,拖拽手势只是档位的另一种输入。类型按钮切换 panelType 后,观察背景卡片是否被顶起即可理解 FOLDBAR 与 TEMPORARY 的布局差异。

4.6 滚动冲突处理:ConflictDemo.ets

面板内 List 与页面背景 Scroll 并存的对照实验:

Stack({ alignContent: Alignment.Bottom }) {
  // 页面背景:可滚动的文章列表
  Scroll() {
    Column({ space: 10 }) {
      ForEach(this.articleTitles(), (item: string, index: number) => {
        // 文章卡片……
      }, (item: string) => item)
    }
    .width('92%')
  }
  .layoutWeight(1)
  .scrollBar(BarState.Off)

  // 底部面板:内部自带 List 滚动
  Panel({ show: true, type: this.panelType, mode: this.panelMode, dragBar: true }) {
    List({ space: 8 }) {
      ForEach(['收藏文章 1', '收藏文章 2', '收藏文章 3', '收藏文章 4', '收藏文章 5'], (item: string) => {
        ListItem() {
          // 收藏行……
        }
      }, (item: string) => item)
    }
    .layoutWeight(1)
    .scrollBar(BarState.Off)
  }
  .mode(this.panelMode)
  .onChange((isVisible: boolean, currentMode: PanelMode) => {
    this.panelMode = currentMode;
  })
}

验证方法:手指在面板内上下滑动,只滚收藏列表;在面板外滑动,只滚背景文章;点击面板右上角类型标签切换 FOLDBAR/TEMPORARY,观察"占位顶起"与"悬浮覆盖"的差异。面板内的手势永远属于面板,这是 ArkUI 滚动容器的手势归属规则,不需要额外拦截。

4.7 模块配置要点

module.json5 声明 EntryAbilitypages/Index 路由,main_pages.json 指向 pages/Index,字符串与颜色资源位于 resources/base/element/。与通用 ArkTS 工程完全一致,不再赘述。


五、模拟器运行与效果展示

5.1 编译运行步骤

  1. 用 DevEco Studio 打开本文配套 ohos/ 目录;
  2. entry/src/main/resources/base/media/ 放入名为 icon.png 的图标(与 module.json5$media:icon 对应);
  3. 顶部选择 Phone 模拟器(或连接真机),点击 Run;
  4. 应用启动后进入 Index 页面,顶部 Tab 可在三个演示间切换。

5.2 预期效果截图

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.3 交互验证

  • 三档拖拽:在地图面板中上滑到 Full、下滑到 Mini、松手回弹到 Half,三档内容随之增删;
  • 受控跳档:在"模式控制"Tab 点击 Mini/Half/Full 按钮,面板动画过渡到指定档位;
  • 类型对比:切换 FOLDBAR/TEMPORARY,观察背景卡片被顶起(占位)还是保持原位(悬浮);
  • 滚动归属:在"滚动冲突"Tab 中分别于面板内外滑动,确认"面板内滚面板、面板外滚页面";
  • 商户选中:点击商户行"查看",变为"✓ 已选中"并 Toast 提示,选中态跨档位保留。

六、调试与常见问题

问题 1:面板不显示。
show 默认是 false,记得在构造参数里显式传 true;另外 Panel 建议放在 Stack 中并给父容器固定高度,避免高度塌缩。

问题 2:拖拽后内容不跟着换。
mode 没绑状态。必须 .mode(this.currentMode) + .onChange 回写,形成受控闭环;只写 onChange 不写 .mode() 时,内容分支用的还是旧值。

问题 3:FOLDBAR 面板跟着页面一起滚走了。
FOLDBAR 是"占位"类型,正常不随内容滚动;若它被包在 Scroll/List 里就会跟着滚。把 Panel 放到页面根容器(与滚动区平级)即可。

问题 4:面板内的列表划不动。
确认面板内用的是 List/Scroll 等自带滚动能力的容器,且给了 layoutWeight(1) 或固定高度——内容没有滚动容器时,手势只会被当作面板拖拽。

问题 5:TEMPORARY 类型下内容被面板盖住。
悬浮是它的设计行为。需要盖住时给 Panel 加背景色与圆角;需要内容让位时改用 FOLDBAR。两种类型不能同时满足"悬浮 + 占位"。

问题 6:拖拽灵敏度与误触。
dragBar: false 隐藏横条后仍可拖拽,但用户少了视觉入口,建议保留;面板内容区有按钮时,按钮点击与拖拽由系统手势判定,按钮区外的空白才是拖拽主区域。

无障碍建议: 面板模式变化应有语义提示——onChange 里更新 accessibilityText(如"面板已展开为全屏"),并把面板内容区的可交互元素暴露给读屏,避免拖拽手势变成读屏的盲区。


七、总结与扩展

滑动面板的使用要点浓缩成四条:

三档即三态
Mini/Half/Full 是同一容器的三个"取景框",内容随档位增删,不复制页面;
受控是必须
mode 绑状态、onChange 回写,拖拽与按钮只是档位的两种输入;
类型先想清
常驻面板用 FOLDBAR(占位),轻量浮层用 TEMPORARY(悬浮);
手势有归属
面板内滚面板、面板外滚页面,别让两套滚动抢一只手。

面板的优雅,在于把"切换"藏进一个手势里。

往深走,有三条值得继续的路:

  1. 与地图联动:把面板模式作为地图缩放/标注显示的状态输入——Mini 显示概览、Half 显示附近、Full 显示详情,形成"面板-地图"双向驱动;
  2. 高度定制:用 fullHeight/halfHeight/miniHeight 精调三档高度,配合 backgroundMaskbackgroundBlurStyle 让面板与背景产生层次感;
  3. 绑定弹层:内容较重的表单场景改用 bindSheet,与 Panel 形成"面板系"组件族,按内容重量选形态。

滑动面板是"手势驱动界面"的典型代表——它把页面的层级切换变成了手指的上下滑动。想清楚三档内容、受控状态与手势归属,这个组件就会像地图 App 里那样自然。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐