HarmonyOS开发之基于子窗口实现应用内悬浮窗

鸿蒙开发:基于子窗口实现应用内悬浮窗(含完整代码示例)

在现代移动应用中,悬浮窗/悬浮球是一种非常实用的交互方式,常用于展示快捷入口、实时通知、视频播放等场景。例如:

  • 聊天应用中的小助手按钮
  • 视频应用的画中画功能
  • 游戏或工具类 App 的全局操作面板

HarmonyOS 提供了 子窗口(SubWindow)机制 ,结合 Window 模块和手势控制能力,开发者可以轻松构建一个支持拖拽、动画靠边、跨页面保留位置、响应点击事件的悬浮窗组件。


🎯 功能需求概述

我们希望实现以下核心功能:

|-------|---------------------------------------|
| 编号 | 功能描述 |
| ✅ 场景一 | 支持动态添加/移除悬浮窗,样式可定制(圆形 & 小视频窗口) |
| ✅ 场景二 | 子窗口创建后,主窗口仍能正常响应系统返回手势(如侧滑返回) |
| ✅ 场景三 | 悬浮窗支持拖拽并自动靠边显示;跳转页面后仍保持位置不变 |
| ✅ 场景四 | 悬浮窗内部点击触发主窗口 Router / Navigation 页面跳转 |
| ✅ 场景五 | 窗口大小自适应内容组件变化 |
| ✅ 场景六 | 支持隐藏和销毁悬浮窗 |
| ✅ 场景七 | 视频类应用支持画中画后台播放与桌面返回自动恢复 |


🧱 技术选型说明

我们使用 HarmonyOS 提供的如下关键模块完成悬浮窗功能:

  • @ohos.window:窗口管理模块,用于创建子窗口、设置布局、监听焦点等
  • Router / Navigation:用于实现主窗口页面跳转逻辑
  • GestureEvent / PanGesture:用于实现拖拽移动
  • AppStorage:存储公共变量如 windowStage、导航栈信息
  • componentUtils:获取组件尺寸用于窗口自适应调整
  • pipWindow:画中画功能专用接口

🛠️ 核心实现步骤

1️⃣ 获取 WindowStage 并保存到全局(EntryAbility)

登录后复制

plain 复制代码
// EntryAbility.ts
import router from '@ohos.router';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 设置主窗口页面
    windowStage.loadContent('pages/Index', (err, data) => {
      if (err.code) {
        console.error('Failed to load content:', JSON.stringify(err));
      }
    });

    // 保存 windowStage 到全局
    globalThis.windowStage = windowStage;
  }
}

💡 注意:通过 globalThisAppStorage 保存 windowStage,以便后续在页面中使用。


2️⃣ 创建子窗口并设置基础样式

登录后复制

plain 复制代码
// 创建子窗口
function createSubWindow() {
  const windowStage = globalThis.windowStage;

  windowStage.createSubWindow("mySubWindow", (err, subWindow) => {
    if (err.code !== 0) {
      console.error('创建子窗口失败', err.message);
      return;
    }

    try {
      // 加载子窗口页面
      subWindow.setUIContent("pages/SubWindowPage");

      // 设置背景透明(无白边)
      subWindow.setWindowBackgroundColor("#00000000");

      // 设置初始位置和大小
      subWindow.moveWindowTo(0, 200); // x=0, y=200
      subWindow.resize(vp2px(75), vp2px(75)); // 宽高 75vp

      // 全屏布局不避让安全区
      subWindow.setWindowLayoutFullScreen(true);

      // 显示子窗口
      subWindow.showWindow();
    } catch (e) {
      console.error('初始化子窗口出错', e);
    }
  });
}

3️⃣ 实现拖拽并自动靠边(带动画效果)

登录后复制

plain 复制代码
@State position: { x: number; y: number } = { x: 0, y: 200 };

Column()
  .width(vp2px(75))
  .height(vp2px(75))
  .gesture(
    PanGesture({ direction: PanDirection.All })
      .onActionStart(() => {
        console.info('拖拽开始');
      })
      .onActionUpdate((event: GestureEvent) => {
        this.position.x += event.offsetX;
        this.position.y += event.offsetY;
        this.subWindow.moveWindowTo(this.position.x, this.position.y);
      })
      .onActionEnd((event: GestureEvent) => {
        const displayWidth = display.getDefaultDisplaySync().width;
        const windowWidth = this.subWindow.getWindowProperties().windowRect.width;

        if (event.offsetX > 0) {
          this.position.x = displayWidth - windowWidth; // 靠右
        } else {
          this.position.x = 0; // 靠左
        }

        this.subWindow.moveWindowTo(this.position.x, this.position.y);
      })
  )

使用 Router 跳转主窗口页面

登录后复制

plain 复制代码
.onClick(() => {
  globalThis.windowStage.getMainWindowSync()
    .getUIContext()
    .getRouter()
    .pushUrl({ url: 'pages/DetailPage' });
});

登录后复制

plain 复制代码
const navPathStack = AppStorage.get<NavPathStack>('navPathStack');

.onClick(() => {
  navPathStack.pushPath({ name: 'pageOne' });
});

5️⃣ 自动适配窗口大小(基于组件变化)

登录后复制

plain 复制代码
@State subWindow: window.Window = null;
private flag: boolean = true;
private listener = component.createEventObserver('COMPONENT_ID');

if (this.flag) {
  Image($r('app.media.icon1'))
    .id('COMPONENT_ID')
    .width(75)
    .height(75)
    .onClick(() => {
      this.flag = false;
      this.listener.on('layout', () => {
        this.subWindow.resize(
          componentUtils.getRectangleById('COMPONENT_ID').size.width,
          componentUtils.getRectangleById('COMPONENT_ID').size.height
        );
      });
    });
} else {
  Image($r('app.media.icon2'))
    .id('COMPONENT_ID')
    .width(100)
    .height(100)
    .onClick(() => {
      this.flag = true;
      this.listener.on('layout', () => {
        this.subWindow.resize(...);
      });
    });
}

6️⃣ 控制悬浮窗显隐与销毁

登录后复制

plain 复制代码
// 最小化
Button('Minimize')
  .onClick(() => {
    this.subWindow.minimize();
  });

// 销毁
Button('Destroy')
  .onClick(() => {
    window.findWindow("mySubWindow").destroyWindow();
  });

7️⃣ 实现视频画中画功能(PiP)

登录后复制

plain 复制代码
import pipWindow from '@ohos.pipWindow';

let pipController: pipWindow.PiPController;

startPip() {
  let config: pipWindow.PiPConfiguration = {
    context: getContext(this),
    componentController: this.mXComponentController,
    templateType: pipWindow.PiPTemplateType.VIDEO_PLAY,
    contentWidth: 800,
    contentHeight: 600
  };

  pipWindow.create(config).then(controller => {
    this.pipController = controller;
    this.pipController.setAutoStartEnabled(true); // 返回桌面自动开启 PiP
    this.pipController.startPiP();
  }).catch(e => {
    console.error('启动画中画失败:', e);
  });
}

stopPip() {
  if (this.pipController) {
    this.pipController.stopPiP();
  }
}

⚙️ 其他注意事项

|-------------------------|-----------------------------------------------------------|
| 问题 | 解答 |
| 如何获取 windowStage? | 在 onWindowStageCreate 中用 AppStorageglobalThis 保存 |
| 子窗口是否支持跨应用? | ❌ 不支持,只能用于应用内部 |
| 默认窗口大小是多少? | 若未设置,默认为除去安全区外的屏幕区域 |
| 可否在 UIExtension 中使用子窗口? | ❌ 不行,UIExtension 没有窗口对象 |
| HAR/HSP 是否可用? | ✅ 只要能获取到 windowStage 即可使用 |


📦 示例工程地址

完整项目源码已上传至 Gitee:

🔗 GitHub / Gitee 下载链接


✅ 总结

通过 HarmonyOS 提供的子窗口机制,我们可以非常灵活地实现各类悬浮窗功能,包括:

  • ✅ 自定义外观与布局
  • ✅ 拖拽靠边智能定位
  • ✅ 跨页面状态保留
  • ✅ 响应主窗口路由跳转
  • ✅ 窗口自适应大小
  • ✅ 画中画后台播放

这为构建更高级别的多窗口协同应用打下了坚实基础。


🔥 推荐阅读


如果你正在开发企业级 App 或需要统一用户界面风格的应用,不妨尝试使用子窗口 + UDMF + 画中画等多种能力组合,打造真正"沉浸式"的用户体验!

📌 欢迎收藏本博客,并关注后续更多 HarmonyOS 实战案例更新!