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

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

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

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


🎯 功能需求概述

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

编号

功能描述

✅ 场景一

支持动态添加/移除悬浮窗,样式可定制(圆形 & 小视频窗口)

✅ 场景二

子窗口创建后,主窗口仍能正常响应系统返回手势(如侧滑返回)

✅ 场景三

悬浮窗支持拖拽并自动靠边显示;跳转页面后仍保持位置不变

✅ 场景四

悬浮窗内部点击触发主窗口 Router / Navigation 页面跳转

✅ 场景五

窗口大小自适应内容组件变化

✅ 场景六

支持隐藏和销毁悬浮窗

✅ 场景七

视频类应用支持画中画后台播放与桌面返回自动恢复


🧱 技术选型说明

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

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

🛠️ 核心实现步骤

1️⃣ 获取 WindowStage 并保存到全局(EntryAbility)
// 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;
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

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


2️⃣ 创建子窗口并设置基础样式
// 创建子窗口
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);
    }
  });
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.

3️⃣ 实现拖拽并自动靠边(带动画效果)
@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);
      })
  )
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

4️⃣ 主窗口响应点击事件(Router / Navigation 跳转)
使用 Router 跳转主窗口页面
.onClick(() => {
  globalThis.windowStage.getMainWindowSync()
    .getUIContext()
    .getRouter()
    .pushUrl({ url: 'pages/DetailPage' });
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
使用 Navigation 跳转(需配合 AppStorage)
const navPathStack = AppStorage.get<NavPathStack>('navPathStack');

.onClick(() => {
  navPathStack.pushPath({ name: 'pageOne' });
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

5️⃣ 自动适配窗口大小(基于组件变化)
@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(...);
      });
    });
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

6️⃣ 控制悬浮窗显隐与销毁
// 最小化
Button('Minimize')
  .onClick(() => {
    this.subWindow.minimize();
  });

// 销毁
Button('Destroy')
  .onClick(() => {
    window.findWindow("mySubWindow").destroyWindow();
  });
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

7️⃣ 实现视频画中画功能(PiP)
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();
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.

⚙️ 其他注意事项

问题

解答

如何获取 windowStage

onWindowStageCreate 中用 AppStorageglobalThis 保存

子窗口是否支持跨应用?

❌ 不支持,只能用于应用内部

默认窗口大小是多少?

若未设置,默认为除去安全区外的屏幕区域

可否在 UIExtension 中使用子窗口?

❌ 不行,UIExtension 没有窗口对象

HAR/HSP 是否可用?

✅ 只要能获取到 windowStage 即可使用


📦 示例工程地址

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

🔗  GitHub / Gitee 下载链接


✅ 总结

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

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

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


🔥 推荐阅读


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

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