鸿蒙原生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 需求拆解

  1. 分层显示:视频画面在底层,控制按钮悬浮在上层。
  2. 半透明遮罩:控制层带渐变半透明背景,不遮挡画面。
  3. 控制层显隐:点击画面切换,实现沉浸式观看。
  4. 点击隔离:点击控制层时,事件不穿透到底层。
  5. 状态联动:播放/暂停、进度条、时间实时联动。

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()) 叠加装饰内容。overlayStack 的区别

  • 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)

如果不设置,点击控制层空白区域会穿透到底层视频的 onClickHitTestMode.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 迁移步骤

  1. 更新 build-profile.json5compatibleSdkVersion 改为 "6.2.0(24)"
  2. 替换 deprecated APIrouter.pushUrl 换为 NavPathStack
  3. 适配 blockSizeblockSize(20)blockSize({ width: 20, height: 20 })
  4. 检查 hitTestBehavior 枚举:使用 HitTestMode.Block
  5. 编译验证:运行 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)只会触发受影响的 TextSlider 重绘,不会重建整个控制层。

9.4 动画性能

opacity 是合成属性,修改它不会触发 relayout,只触发重绘,性能极高。

animateTo({ duration: 200 }, () => {
  this.controlOpacity = this.isControlVisible ? 1.0 : 0.0;
});

十、总结

本文通过完整的播放器控制覆盖层示例,深入讲解了 HarmonyOS NEXT 中 Stack 布局的核心原理和实战技巧。

核心要点

  1. Stack 是 Z 轴层叠布局的唯一原生解,用于播放器覆盖层、悬浮按钮、弹窗等场景。
  2. hitTestBehavior(HitTestMode.Block) 是覆盖层不可或缺的配置,防止事件穿透。
  3. linearGradient 三段式渐变在不遮挡画面的前提下,保证控制按钮的可读性。
  4. @State + 属性绑定实现声明式 UI 状态管理。
  5. API 24 在路由、Slider 参数、hitTestBehavior 枚举等方面有重要变更。

扩展场景

Stack 布局的应用远不止播放器:

  • 图片查看器:图片 + 顶部导航 + 底部操作栏
  • 直播界面:视频流 + 弹幕层 + 礼物特效层
  • 地图应用:地图 + 定位标记 + 底部卡片
  • 游戏 HUD:游戏画面 + 血量/分数覆盖层
  • 引导蒙层:功能页面 + 半透明高亮层

掌握了 Stack 的布局思想,你将为鸿蒙应用构建出层次丰富、交互流畅的现代用户界面。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐