【共创季稿事节】鸿蒙原生ArkTS布局实战:Stack实现播放器控制覆盖层
鸿蒙原生ArkTS布局实战:Stack实现播放器控制覆盖层



一、引言
在移动端应用中,视频播放器是最常见的UI场景之一。核心挑战在于将「视频画面层」与「交互控制层」在同一屏幕内无缝叠放,并隔离事件响应。
传统布局方式(如线性布局)实现这种「层层覆盖」往往需要复杂的嵌套或绝对定位,不仅代码冗长,还导致布局层级过深,影响渲染性能。
HarmonyOS NEXT 的 ArkUI 框架提供了 Stack 容器组件,以 Z轴(深度方向) 的层叠布局完美解决了这一难题。本文从播放器控制覆盖层的完整示例出发,讲解 Stack 布局的核心原理、实战技巧及 API 24 下的最佳实践。
二、HarmonyOS NEXT 与 ArkUI 框架
2.1 HarmonyOS NEXT 的里程碑意义
HarmonyOS NEXT 是从底层内核到应用框架完全自研的操作系统,彻底移除 AOSP 代码。应用必须使用 ArkTS 或 C/C++ 开发,不再兼容 APK,UI 框架统一为声明式的 ArkUI。API 24(HarmonyOS 5.0)已进入非常成熟的阶段。
2.2 ArkUI 声明式 UI 三大特征
| 要点 | 说明 | 代码体现 |
|---|---|---|
| 状态驱动 | UI 是状态的函数,变化自动触发更新 | @State isPlaying → 按钮图标变化 |
| 组件化 | 一切皆组件,可嵌套、可复用 | @Component struct PlayerOverlayPage |
| 链式配置 | 通过 .属性() 链式调用设置样式 |
.width('100%').height('100%') |
三、Stack 布局原理解析
3.1 什么是 Stack?
Stack 是 ArkUI 中的容器组件,其子组件按添加顺序在 Z 轴上依次层叠——先添加在底层,后添加在顶层。子组件默认在两个轴向上拉伸填满容器。
Z 轴(从屏幕向外)
↑ ┌──────────────────┐
│ │ 控制层(上层) │ ← 后添加
│ ├──────────────────┤
│ │ 视频层(底层) │ ← 先添加
│ └──────────────────┘
└─────────────────────────→ X 轴
3.2 各布局容器对比
| 容器 | 方向 | 适用场景 |
|---|---|---|
Column |
垂直 | 列表、表单 |
Row |
水平 | 工具栏、按钮组 |
Flex |
灵活一维 | 自适应排列 |
RelativeContainer |
锚点定位 | 复杂仪表盘 |
Stack |
Z轴(深度) | 播放器覆盖层、悬浮按钮、弹窗 |
3.3 Stack 的核心属性(API 24)
alignContent:子组件默认对齐方式(默认Center)。hitTestBehavior:点击穿透行为——HitTestMode.Block是覆盖层的关键配置。clip:是否裁剪超出边界的子组件。
四、场景分析:播放器控制覆盖层
4.1 需求拆解
- 分层显示:视频画面在底层,控制按钮悬浮在上层。
- 半透明遮罩:控制层带渐变半透明背景,不遮挡画面。
- 控制层显隐:点击画面切换,实现沉浸式观看。
- 点击隔离:点击控制层时,事件不穿透到底层。
- 状态联动:播放/暂停、进度条、时间实时联动。
4.2 Stack 方案 vs 传统方案
| 需求 | 传统方案 | Stack 方案 |
|---|---|---|
| 分层显示 | absolute + relative |
天然 Z 轴 |
| 半透明遮罩 | 额外布局层 | linearGradient |
| 点击隔离 | onInterceptTouchEvent |
HitTestMode.Block |
| 状态联动 | 手动刷新 | @State 自动驱动 |
五、核心代码实现(PlayerOverlayPage.ets)
/**
* 播放器控制覆盖层 —— Stack 布局核心演示
*
* 布局层次:
* Stack 根容器
* ├─ 第1层(底层): 视频画面区域(模拟)
* └─ 第2层(上层): 半透明控制覆盖层
* ├─ 顶部导航栏(返回|标题|更多)
* ├─ 中央播放/暂停按钮
* └─ 底部进度控制区(进度条+时间+全屏)
*/
import { router } from '@kit.ArkUI';
@Builder
function VideoOverlayContent() {
Column() {
Path().commands('M 0 0 L 60 40 L 0 80 Z')
.fill(Color.White).opacity(0.15)
.width(80).height(80)
Text('▶ 视频画面区域')
.fontColor(Color.White).fontSize(18)
.opacity(0.4).margin({ top: 16 })
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Entry
@Component
struct PlayerOverlayPage {
@State isPlaying: boolean = false;
@State currentTime: number = 0;
@State duration: number = 240;
@State isControlVisible: boolean = true;
@State controlOpacity: number = 1.0;
private videoTitle: string = 'HarmonyOS NEXT 播放器示例';
build() {
// 【核心】Stack —— 让视频层和控制层在 Z 轴上叠放
Stack() {
// ========== 第1层:视频画面 ==========
Column() {
Column()
.width('100%').height('100%')
.backgroundColor('#1a1a2e')
.overlay(VideoOverlayContent())
.onClick(() => { this.toggleControlVisibility(); })
}
.width('100%').height('100%')
// ========== 第2层:控制覆盖层 ==========
Column() {
/* 顶部导航栏 */
Row() {
Row() {
Text('←').fontSize(22).fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(40).height(40)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.borderRadius(20)
.onClick(() => { router.back(); })
Text(this.videoTitle)
.fontColor(Color.White).fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ left: 12 }).layoutWeight(1)
Row() {
Text('⋮').fontSize(24).fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(40).height(40)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.borderRadius(20)
}
.width('100%').height(56).padding({ left: 8, right: 8 })
.alignItems(VerticalAlign.Center)
Blank().layoutWeight(1) // 弹性上间隔
/* 中央:播放/暂停大按钮 */
Row() {
Button() {
Text(this.isPlaying ? '❚❚' : '▶')
.fontSize(32).fontColor('#ff6b6b')
.fontWeight(FontWeight.Bold)
}
.type(ButtonType.Circle)
.width(80).height(80)
.backgroundColor(Color.White).opacity(0.9)
.shadow({ radius: 20, color: 'rgba(0,0,0,0.3)',
offsetX: 0, offsetY: 8 })
.onClick(() => { this.togglePlay(); })
}
.width('100%').justifyContent(FlexAlign.Center)
Blank().layoutWeight(1) // 弹性下间隔
/* 底部:进度控制区 */
Column() {
Slider({ value: this.currentTime, min: 0,
max: this.duration, step: 1,
style: SliderStyle.OutSet })
.width('100%')
.trackThickness(4)
.blockSize({ width: 20, height: 20 })
.blockColor('#ff6b6b')
.trackColor('rgba(255,255,255,0.3)')
.selectedColor('#ff6b6b')
.showTips(true)
.onChange((value: number) => {
this.currentTime = value; })
Row() {
Text(this.formatTime(this.currentTime) +
' / ' + this.formatTime(this.duration))
.fontColor('rgba(255,255,255,0.8)')
.fontSize(13)
Blank().layoutWeight(1)
Row() {
Text('⛶').fontSize(22).fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(36).height(36)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.borderRadius(18)
}
.width('100%').margin({ top: 8, bottom: 4 })
}
.width('100%').padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%').height('100%')
// 【关键】半透明渐变遮罩
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
['rgba(0,0,0,0.6)', 0],
['rgba(0,0,0,0.1)', 0.5],
['rgba(0,0,0,0.5)', 1]
]
})
.opacity(this.controlOpacity)
.hitTestBehavior(HitTestMode.Block) // 防点击穿透
.onClick(() => { this.toggleControlVisibility(); })
}
.width('100%').height('100%')
.backgroundColor('#0d0d0d')
}
togglePlay(): void {
this.isPlaying = !this.isPlaying;
}
toggleControlVisibility(): void {
this.isControlVisible = !this.isControlVisible;
this.controlOpacity = this.isControlVisible ? 1.0 : 0.0;
}
formatTime(seconds: number): string {
const total: number = Math.floor(seconds);
const mins: number = Math.floor(total / 60);
const secs: number = total % 60;
return `${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
}
}
配套路由注册
主页导航按钮:
Button() {
Text('▶ 播放器控制覆盖层 (Stack)')
.fontColor(Color.White).fontSize(16)
}
.width('80%').height(56)
.backgroundColor('#ff6b6b').borderRadius(12)
.onClick(() => {
router.pushUrl({ url: 'pages/PlayerOverlayPage', params: {} });
})
main_pages.json 中注册路由:
{ "src": ["pages/Index", "pages/PlayerOverlayPage"] }
六、布局要点逐行解读
6.1 Stack 根容器
Stack() { ... }
.width('100%').height('100%')
.backgroundColor('#0d0d0d')
Stack 与父容器等宽等高,子组件默认以 Center 居中对齐并撑满容器。
6.2 视频层
视频层的 Column 使用 .overlay(VideoOverlayContent()) 叠加装饰内容。overlay 与 Stack 的区别:
Stack是多子组件之间的 Z 轴排列,子组件彼此独立;.overlay()是单组件的装饰层,与宿主绑定,随宿主一起移动。
6.3 控制层的三段式结构
控制层 Column 内部按「顶部—中间—底部」组织:
Blank().layoutWeight(1) ← 弹性上间隔
播放按钮(固定 80×80) ← 自然居中
Blank().layoutWeight(1) ← 弹性下间隔
Blank 组件占据 layoutWeight 分配的剩余空间,将按钮「推」到垂直居中位置。
6.4 底部进度区
Slider 在 API 24 中的关键配置:
Slider({ /* ... */ })
.trackThickness(4) // 轨道粗细
.blockSize({ width: 20, height: 20 }) // 【API 24】使用 SizeOptions 对象
.showTips(true) // 滑动时显示气泡提示
注意: API 24 中
blockSize参数类型从number变更为SizeOptions对象。
6.5 控制层渐变背景
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
['rgba(0,0,0,0.6)', 0], // 顶部稍暗,衬托文字
['rgba(0,0,0,0.1)', 0.5], // 中间几乎透明,看清画面
['rgba(0,0,0,0.5)', 1] // 底部略暗,衬托控制栏
]
})
三段式渐变比纯色半透明背景更自然——用户既能看清按钮,又能最大程度观赏画面中央。
6.6 hitTestBehavior——最关键的一行
.hitTestBehavior(HitTestMode.Block)
如果不设置,点击控制层空白区域会穿透到底层视频的 onClick。HitTestMode.Block 拦截所有点击事件,不向后方传递。
API 24 中 HitTestMode 枚举:
| 枚举值 | 行为 | 适用场景 |
|---|---|---|
Default |
先测上层,未命中则逐层向下 | 默认 |
Block |
不向下层传递 | 覆盖层、模态弹窗 |
Transparent |
穿透到底层 | 透明引导层 |
None |
不参与点击测试 | 禁用交互区域 |
七、从 API 23 到 API 24:关键升级变化
7.1 主要变更
| 模块 | API 23 | API 24 |
|---|---|---|
| 路由 | router.pushUrl() |
推荐 NavPathStack |
Slider.blockSize |
blockSize(value: number) |
blockSize(size: SizeOptions) |
hitTestBehavior |
枚举命名不同 | 统一 HitTestMode |
@Builder 参数 |
不支持传递 @State |
支持传递 @State |
7.2 迁移步骤
- 更新
build-profile.json5:compatibleSdkVersion改为"6.2.0(24)"。 - 替换 deprecated API:
router.pushUrl换为NavPathStack。 - 适配
blockSize:blockSize(20)→blockSize({ width: 20, height: 20 })。 - 检查
hitTestBehavior枚举:使用HitTestMode.Block。 - 编译验证:运行
hvigorw assembleHap。
// build-profile.json5 — API 24 配置
{
"app": {
"products": [{
"name": "default",
"targetSdkVersion": "6.2.0(24)",
"compatibleSdkVersion": "6.2.0(24)",
"runtimeOS": "HarmonyOS"
}]
}
}
八、进阶:对接真实 Video 组件
将模拟画面替换为真实 Video 组件:
Stack() {
Video({
src: $rawfile('demo_video.mp4'),
currentProgressRate: 1.0,
controller: this.videoController
})
.width('100%').height('100%')
.controls(false) // 隐藏系统控制栏
.onUpdate((info: VideoEventInfo) => {
this.currentTime = info.time;
this.duration = info.duration;
})
.onClick(() => { this.toggleControlVisibility(); })
// 控制层代码同上...
Column() { /* ... */ }
.hitTestBehavior(HitTestMode.Block)
}
关键差异:
controls(false)隐藏系统控制栏,由自定义覆盖层接管;onUpdate每 250ms 触发,驱动Slider和时间显示;- 通过
VideoController实现播放/暂停/seek。
九、性能优化与最佳实践
9.1 减少布局层级
Stack 本身高效,但不要在内部过度嵌套。本示例嵌套深度不超过 4 层,对 ArkUI 渲染引擎非常轻量。
9.2 合理使用 @Builder
复用内容提取为 @Builder 函数,减少重复代码,帮助编译器优化渲染。
@Builder
function ControlButton(icon: string, onClick: () => void) {
Button() {
Text(icon).fontSize(24).fontColor(Color.White)
}
.width(48).height(48)
.type(ButtonType.Circle)
.backgroundColor('rgba(255,255,255,0.2)')
.onClick(onClick)
}
9.3 控制状态刷新范围
高频更新的变量(如 currentTime)只会触发受影响的 Text 和 Slider 重绘,不会重建整个控制层。
9.4 动画性能
opacity 是合成属性,修改它不会触发 relayout,只触发重绘,性能极高。
animateTo({ duration: 200 }, () => {
this.controlOpacity = this.isControlVisible ? 1.0 : 0.0;
});
十、总结
本文通过完整的播放器控制覆盖层示例,深入讲解了 HarmonyOS NEXT 中 Stack 布局的核心原理和实战技巧。
核心要点
Stack是 Z 轴层叠布局的唯一原生解,用于播放器覆盖层、悬浮按钮、弹窗等场景。hitTestBehavior(HitTestMode.Block)是覆盖层不可或缺的配置,防止事件穿透。linearGradient三段式渐变在不遮挡画面的前提下,保证控制按钮的可读性。@State+ 属性绑定实现声明式 UI 状态管理。- API 24 在路由、Slider 参数、hitTestBehavior 枚举等方面有重要变更。
扩展场景
Stack 布局的应用远不止播放器:
- 图片查看器:图片 + 顶部导航 + 底部操作栏
- 直播界面:视频流 + 弹幕层 + 礼物特效层
- 地图应用:地图 + 定位标记 + 底部卡片
- 游戏 HUD:游戏画面 + 血量/分数覆盖层
- 引导蒙层:功能页面 + 半透明高亮层
掌握了 Stack 的布局思想,你将为鸿蒙应用构建出层次丰富、交互流畅的现代用户界面。
更多推荐




所有评论(0)