鸿蒙Panel 滑动面板完全指南:半屏/全屏切换、拖拽交互与地图应用
Panel 滑动面板完全指南:半屏/全屏切换、拖拽交互与地图应用
本文基于 HarmonyOS(ArkTS 声明式开发范式,API 12 / 5.0.0)写作,所有示例均可在 DevEco Studio 模拟器中验证。配套演示工程位于本文同级目录
ohos/,包含完整可运行的EntryAbility.ets与Index.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 起可用。本文用到的能力(三模式、两类型、onChange、dragBar)在 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.ets、model/ShopModel.ets与components/下的组件代码,其余配置沿用原工程即可。
三、核心 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 搜索框加 List;mode 与 onChange 双向绑定,拖到哪档内容跟到哪档,顶部徽标同步显示当前模式。
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 声明 EntryAbility 与 pages/Index 路由,main_pages.json 指向 pages/Index,字符串与颜色资源位于 resources/base/element/。与通用 ArkTS 工程完全一致,不再赘述。
五、模拟器运行与效果展示
5.1 编译运行步骤
- 用 DevEco Studio 打开本文配套
ohos/目录; - 在
entry/src/main/resources/base/media/放入名为icon.png的图标(与module.json5中$media:icon对应); - 顶部选择 Phone 模拟器(或连接真机),点击 Run;
- 应用启动后进入
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(悬浮); 手势有归属
- 面板内滚面板、面板外滚页面,别让两套滚动抢一只手。
面板的优雅,在于把"切换"藏进一个手势里。
往深走,有三条值得继续的路:
- 与地图联动:把面板模式作为地图缩放/标注显示的状态输入——Mini 显示概览、Half 显示附近、Full 显示详情,形成"面板-地图"双向驱动;
- 高度定制:用
fullHeight/halfHeight/miniHeight精调三档高度,配合backgroundMask与backgroundBlurStyle让面板与背景产生层次感; - 绑定弹层:内容较重的表单场景改用
bindSheet,与 Panel 形成"面板系"组件族,按内容重量选形态。
滑动面板是"手势驱动界面"的典型代表——它把页面的层级切换变成了手指的上下滑动。想清楚三档内容、受控状态与手势归属,这个组件就会像地图 App 里那样自然。
更多推荐

所有评论(0)