鸿蒙开发:基于子窗口实现应用内悬浮窗(含完整代码示例)
在现代移动应用中,悬浮窗/悬浮球是一种非常实用的交互方式,常用于展示快捷入口、实时通知、视频播放等场景。例如:
- 聊天应用中的小助手按钮
- 视频应用的画中画功能
- 游戏或工具类 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;
}
}
💡 注意:通过
globalThis
或AppStorage
保存 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);
})
)
4️⃣ 主窗口响应点击事件(Router / Navigation 跳转)
使用 Router 跳转主窗口页面
登录后复制
plain
.onClick(() => {
globalThis.windowStage.getMainWindowSync()
.getUIContext()
.getRouter()
.pushUrl({ url: 'pages/DetailPage' });
});
使用 Navigation 跳转(需配合 AppStorage)
登录后复制
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
中用 AppStorage
或 globalThis
保存 |
| 子窗口是否支持跨应用? | ❌ 不支持,只能用于应用内部 |
| 默认窗口大小是多少? | 若未设置,默认为除去安全区外的屏幕区域 |
| 可否在 UIExtension 中使用子窗口? | ❌ 不行,UIExtension 没有窗口对象 |
| HAR/HSP 是否可用? | ✅ 只要能获取到 windowStage 即可使用 |
📦 示例工程地址
完整项目源码已上传至 Gitee:
✅ 总结
通过 HarmonyOS 提供的子窗口机制,我们可以非常灵活地实现各类悬浮窗功能,包括:
- ✅ 自定义外观与布局
- ✅ 拖拽靠边智能定位
- ✅ 跨页面状态保留
- ✅ 响应主窗口路由跳转
- ✅ 窗口自适应大小
- ✅ 画中画后台播放
这为构建更高级别的多窗口协同应用打下了坚实基础。
🔥 推荐阅读
如果你正在开发企业级 App 或需要统一用户界面风格的应用,不妨尝试使用子窗口 + UDMF + 画中画等多种能力组合,打造真正"沉浸式"的用户体验!
📌 欢迎收藏本博客,并关注后续更多 HarmonyOS 实战案例更新!