【鸿蒙实战开发】基于子窗口实现应用内悬浮窗
场景描述
app应用会使用悬浮窗/悬浮球的方式来给用户展示一些应用重要&便捷功能的入口,类似android和iOS应用中常见的应用内可拖拽的悬浮球和小窗口视频悬浮窗,点击悬浮窗修改悬浮窗样式和响应事件跳转页面,在跳转页面后依然可以显示在屏幕中上个页面拖拽后的固定位置等。
应用经常会遇到如下的业务诉求:
场景一:通过事件添加和移除悬浮窗,悬浮窗样式可定制(暂定两种,无白边圆球形和小视频播放窗口类型),可代码修改位置和布局。 场景二:创建悬浮窗后,主窗口的系统侧滑返回事件可正常使用。 场景三:可响应正常点击事件,可通过触发拖动使悬浮窗的移动,根据最后手势停留位置,做动画靠屏幕左或靠右显示,跳转和返回上级页面后悬浮窗依然存在,且相对手机屏幕位置不变。 场景四:悬浮窗内组件事件触发主窗口的页面跳转(Router和Navigation两种都要有)。 场景五:悬浮窗的窗口大小自适应组件,子窗口中页面设置了宽高,需要让子窗口自适应页面组件大小。 场景六:支持控制悬浮窗隐藏和销毁。 场景七:视频类应用主动调用画中画完成后台播放,以及返回桌面时自动启动画中画。
方案描述
场景一:
通过事件添加和移除悬浮窗,悬浮窗样式可定制(暂定两种,无白边圆球形和小视频播放窗口类型),可代码修改位置和布局。
效果图
方案
通过子窗口创建windowStage.createSubWindow('mySubWindow'),和windowClass.setWindowLayoutFullScreen去除白边。
核心代码
▼text复制代码在EntryAbility中获取WindowStage。 onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Page', (err, data) => { if (err.code) { hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); return; } // 保存窗口管理器 AppStorage.setOrCreate("windowStage", windowStage); hilog.info(0x0000, 'testTag', 'Succeeded in loading the content. Data: %{public}s', JSON.stringify(data) ?? ''); }); }
创建子窗口,子窗口样式由子窗口加载的页面组件样式决定。
▼text复制代码this.windowStage.createSubWindow("mySubWindow", (err, windowClass) => { if (err.code > 0) { console.error("failed to create subWindow Cause:" + err.message) return; } try { // 设置子窗口加载页 windowClass.setUIContent("pages/MySubWindow", () => { windowClass.setWindowBackgroundColor("#00000000") }); // 设置子窗口左上角坐标 windowClass.moveWindowTo(0, 200) // 设置子窗口大小 windowClass.resize(vp2px(75), vp2px(75)) // 展示子窗口 windowClass.showWindow(); // 设置子窗口全屏化布局不避让安全区 windowClass.setWindowLayoutFullScreen(true); } catch (err) { console.error("failed to create subWindow Cause:" + err) } })
场景二:
创建悬浮窗后,主窗口的系统侧滑返回事件可正常使用。
效果图
方案
通过window.shiftAppWindowFocus转移窗口焦点实现创建子窗口后,主窗口依然可以响应事件。核心代码
在子窗口中将焦点转移到主窗口。
▼text复制代码onPageShow(): void { setTimeout(() => { // 获取子窗口ID let subWindowID: number = window.findWindow("mySubWindow").getWindowProperties().id // 获取主窗口ID let mainWindowID: number = this.windowStage.getMainWindowSync().getWindowProperties().id // 将焦点从子窗口转移到主窗口 window.shiftAppWindowFocus(subWindowID, mainWindowID) }, 500) }
场景三:
可响应正常点击事件,可通过拖动触发悬浮窗的拖拽移动,根据最后手势停留位置,做动画靠屏幕左或靠右显示,跳转和返回上级页面后悬浮窗依然存在,且相对手机屏幕位置不变。
效果图
方案
通过设置手势顺序模式识别PanGesture,实现拖拽悬浮窗。
核心代码
创建Position。
▼text复制代码interface Position { x: number, y: number }
设置拖拽选项。
▼text复制代码private panOption: PanGestureOptions = new PanGestureOptions({ direction: PanDirection.All });
通过在子窗口父组件绑定拖拽动作完成悬浮窗坐标移动。
▼text复制代码.gesture( // 声明该组合手势的类型为Sequence类型 PanGesture(this.panOption) .onActionStart((event: GestureEvent) => { console.info('Pan start'); })// 发生拖拽时,获取到触摸点的位置,并将位置信息传递给windowPosition .onActionUpdate((event: GestureEvent) => { this.windowPosition.x += event.offsetX; this.windowPosition.y += event.offsetY; this.subWindow.moveWindowTo(this.windowPosition.x, this.windowPosition.y) }) .onActionEnd((event: GestureEvent) => { // 贴边判断 if (event.offsetX > 0) { this.windowPosition.x = display.getDefaultDisplaySync().width - this.subWindow.getWindowProperties() .windowRect .width; } else if (event.offsetX < 0) { this.windowPosition.x = 0; } this.subWindow.moveWindowTo(this.windowPosition.x, this.windowPosition.y) console.info('Pan end'); }) )
场景四:
悬浮窗内组件事件触发主窗口的页面跳转(Router和Navigation两种都要有)。
方案
通过获取窗口上下文,实现在悬浮窗点击后,实现主窗口Router跳转。
通过配置NavPathStack全局变量,实现主窗口navigation跳转 。
核心代码
通过windowStage获取主窗口的Router,实现主窗口的Router跳转。
▼text复制代码.onClick((event: ClickEvent) => { this.windowStage.getMainWindowSync() .getUIContext() .getRouter() .back() })
通过AppStorage获取NavPathStack,实现主窗口navigation跳转。
▼text复制代码.onClick((event: ClickEvent) => { let navPath = AppStorage.get("pageInfos") as NavPathStack; navPath.pushPath({ name: 'pageOne' }) })
场景五:
悬浮窗的窗口大小自适应组件,子窗口中页面设置了宽高,需要让子窗口自适应页面组件大小。
效果图
方案
通过监听通用事件ComponentObserver,设置window的resize调整窗口大小。
核心代码
查找子窗口。
▼text复制代码@State subWindow: window.Window = window.findWindow("mySubWindow");
注册监听事件。
▼text复制代码//监听id为COMPONENT_ID的组件回调事件listener: inspector.ComponentObserver = inspector.createComponentObserver('COMPONENT_ID'); 通过onClick()事件,实现对组件变化的监听。 if (this.flag) { Image($r("app.media.voice2")) .id("COMPONENT_ID") .borderRadius(5) .width(75) .height(75) .onClick(() => { // 设置图标切换标识 this.flag = !this.flag this.listener.on('layout', () => { // 监听布局变更后调整子窗大小 this.subWindow.resize(componentUtils.getRectangleById("COMPONENT_ID").size.width, componentUtils.getRectangleById("COMPONENT_ID").size.height) }) }) } else { Image($r("app.media.voice")) .id("COMPONENT_ID") .borderRadius(50) .width(100) .height(100) .onClick(() => { this.flag = !this.flag this.listener.on('layout', () => { this.subWindow.resize(componentUtils.getRectangleById("COMPONENT_ID").size.width, componentUtils.getRectangleById("COMPONENT_ID").size.height) }) })
场景六:
支持控制悬浮窗隐藏和销毁。
效果图
方案
通过设置窗口windowClass.minimize和windowClass.destroyWindow,实现悬浮窗的隐藏和销毁。
核心代码
通过调用minimize,实现子窗口最小化。
▼text复制代码.onClick((event: ClickEvent) => { this.subWindow.minimize() })
通过实现destroyWindow,实现子窗口的资源销毁。
▼text复制代码// 通过查找子窗口名称对子窗口进行销毁 window.findWindow("mySubWindow").destroyWindow()
场景七:
视频类应用主动调用画中画完成后台播放,以及返回桌面时自动启动画中画。
效果图
方案
1.通过pipController.startPiP()完成主动调用画中画功能。
2.通过pipController.setAutoStartEnabled(true)在返回桌面时完成全局画中画播放。
核心代码
创建XComponent组件。
▼text复制代码XComponent({ id: 'pipDemo', type: 'surface', controller: this.mXComponentController }) .onLoad(() => { this.surfaceId = this.mXComponentController.getXComponentSurfaceId(); // 需要设置AVPlayer的surfaceId为XComponentController的surfaceId this.player = new AVPlayerDemo(this.surfaceId); this.player.avPlayerFdSrcDemo(); }) .onDestroy(() => { console.info(`[${TAG}] XComponent onDestroy`); }) .size({ width: '100%', height: '800px' }) 创建pipWindowController和startPip方法。 startPip() { if (!pipWindow.isPiPEnabled()) { console.error(`picture in picture disabled for current OS`); return; } let config: pipWindow.PiPConfiguration = { context: getContext(this), componentController: this.mXComponentController, // 当前page导航id navigationId: this.navId, // 对于视频通话、视频会议等场景,需要设置相应的模板类型 templateType: pipWindow.PiPTemplateType.VIDEO_PLAY, // 可选,创建画中画控制器时系统可通过XComponent组件大小设置画中画窗口比例 contentWidth: 800, // 可选,创建画中画控制器时系统可通过XComponent组件大小设置画中画窗口比例 contentHeight: 600, }; // 步骤1:创建画中画控制器,通过create接口创建画中画控制器实例 let promise: Promise<pipWindow.PiPController> = pipWindow.create(config); promise.then((controller: pipWindow.PiPController) => { this.pipController = controller; // 步骤1:初始化画中画控制器 this.initPipController(); // 步骤2:通过startPiP接口启动画中画 this.pipController.startPiP().then(() => { console.info(`Succeeded in starting pip.`); }).catch((err: BusinessError) => { console.error(`Failed to start pip. Cause:${err.code}, message:${err.message}`); }); }).catch((err: BusinessError) => { console.error(`Failed to create pip controller. Cause:${err.code}, message:${err.message}`); }); }
初始化pipWindowController。
▼text复制代码initPipController() { if (!this.pipController) { return; } // 通过setAutoStartEnabled接口设置是否需要在应用返回桌面时自动启动画中画,注册stateChange和controlPanelActionEvent回调 this.pipController.setAutoStartEnabled(true/*or true if necessary*/); // 默认为false this.pipController.on('stateChange', (state: pipWindow.PiPState, reason: string) => { this.onStateChange(state, reason); }); this.pipController.on('controlPanelActionEvent', (event: pipWindow.PiPActionEventType) => { this.onActionEvent(event); }); }
完成画中画播放使用stopPip方法停止。
▼text复制代码stopPip() { if (this.pipController) { let promise: Promise<void> = this.pipController.stopPiP(); promise.then(() => { console.info(`Succeeded in stopping pip.`); this.pipController?.off('stateChange'); // 如果已注册stateChange回调,停止画中画时取消注册该回调 this.pipController?.off('controlPanelActionEvent'); // 如果已注册controlPanelActionEvent回调,停止画中画时取消注册该回调 }).catch((err: BusinessError) => { console.error(`Failed to stop pip. Cause:${err.code}, message:${err.message}`); }); } }
其他常见问题
Q:windowStage怎么获取?
A:WindowStage需要在EntryAbility中的onWindowStageCreate中用AppStorage.setOrCreate()获取。
Q:子窗口可以用于应用外么?
A:子窗口只能在应用内使用。
Q:子窗口的默认大小是多大?
A:子窗口默认不设置大小的话是除安全区外的屏幕区域。
Q:UIExtension可以用子窗口么?
A:UIExtension不是窗口对象,没有办法调用窗口接口。
Q:Har和Hsp中可以使用子窗口么?
A:只要能获取到windowStage就能创建并使用子窗口。
写在最后
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
- 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
- 关注小编,同时可以期待后续文章ing🚀,不定期分享原创知识。
- 想要获取更多完整鸿蒙最新学习知识点,请移步前往小编:
https://gitee.com/MNxiaona/733GH/blob/master/qita.md

