实战鸿蒙:如何实现应用悬浮窗

吐槽安卓

不知道你有没有在安卓上做过悬浮窗的需求,开发体验那个是一言难尽。

如果你要做的是系统级别的悬浮窗,就需要判断是否具备悬浮窗权限。然而这又不是一个标准的动态权限,你需要兼容各种奇葩机型的悬浮窗权限判断,下面的代码来自于某著名开源库: EasyFloat

kotlin 复制代码
fun checkPermission(context: Context): Boolean =
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) when {
    RomUtils.checkIsHuaweiRom() -> huaweiPermissionCheck(context)
    RomUtils.checkIsMiuiRom() -> miuiPermissionCheck(context)
    RomUtils.checkIsOppoRom() -> oppoROMPermissionCheck(context)
    RomUtils.checkIsMeizuRom() -> meizuPermissionCheck(context)
    RomUtils.checkIs360Rom() -> qikuPermissionCheck(context)
            else -> true
} else commonROMPermissionCheck(context)

private fun commonROMPermissionCheck(context: Context): Boolean =
        if (RomUtils.checkIsMeizuRom()) meizuPermissionCheck(context) else {
            var result = true
            if (Build.VERSION.SDK_INT >= 23) try {
                val clazz = Settings::class.java
                val canDrawOverlays = clazz.getDeclaredMethod("canDrawOverlays", Context::class.java)
                result = canDrawOverlays.invoke(null, context) as Boolean
            } catch (e: Exception) {
                Log.e(TAG, Log.getStackTraceString(e))
            }
            result
        }

如果你要做的是应用内的全局悬浮窗,那么对不起,不支持,自己想办法。普遍的做法是在根布局 DecorView 直接塞进去。

遥遥领先

在鸿蒙上实现悬浮窗相对就要简单的多。

对于系统级别弹窗,仍然需要权限,但也不至于那么麻烦的适配。

对于应用内全局弹出,鸿蒙提供了 应用子窗口 可以直接实现。

本文着重介绍如何利用应用子窗口实现应用内全局悬浮窗。

创建应用子窗口需要先拿到窗口管理器 WindowStage 对象,在 EntryAbility.onWindowStageCreate() 回调中取。

javascript 复制代码
FloatManager.setWindowStage(windowStage)

setWindowStage(windowStage: window.WindowStage) {
  this.windowStage_ = windowStage
}

然后通过 WindowStage.createSubWindow() 创建子窗口。

ts 复制代码
// 创建子窗口
showSubWindow() {
    if (this.windowStage_ == null) {
        Log.error(TAG, 'Failed to create the subwindow. Cause: windowStage_ is null');
    } else {
        this.windowStage_.createSubWindow("HarmonyWorld", (err: BusinessError, data) => {
            ...
            this.sub_windowClass = data;
            // 子窗口创建成功后,设置子窗口的位置、大小及相关属性等
            // moveWindowTo 和 resize 都可以重复调用,实现拖拽效果
            this.sub_windowClass.moveWindowTo(this.locationX, this.locationY, (err: BusinessError) => {
                ...
            });
            this.sub_windowClass.resize(this.size, this.size, (err: BusinessError) => {
                ...
            });
            // 给子窗口设置内容
            this.sub_windowClass.setUIContent("pages/float/FloatPage", (err: BusinessError) => {
                ...
                // 显示子窗口。
                (this.sub_windowClass as window.Window).showWindow((err: BusinessError) => {
                    ...
                    // 设置透明背景
                    data.setWindowBackgroundColor("#00000000")
                });
            });
        })
    }
}

这样就可以在指定位置显示指定大小的的悬浮窗了。

然后再接着完善手势拖动和点击事件。

既要监听拖动,又要监听手势,就需要通过 GestoreGroup,并把设置模式设置为 互斥识别

less 复制代码
@Entry
@Component
export struct FloatPage {
  private context = getContext(this) as common.UIAbilityContext

  build() {
    Column() {
      Image($r('app.media.mobile_dev'))
        .width('100%')
        .height('100%')
    }
    .gesture(
      GestureGroup(GestureMode.Exclusive,
        // 监听拖动
        PanGesture()
          .onActionUpdate((event: GestureEvent | undefined) => {
            if (event) {
              // 更新悬浮窗位置
              FloatManager.updateLocation(event.offsetX, event.offsetY)
            }
          }),
        // 监听点击
        TapGesture({ count: 1 })
          .onAction(() => {
             router.pushUrl(...)
          }))
    )
  }
}

在拖动手势 PanGestureonActionUpdate() 回调中,可以实时拿到拖动的距离,然后通过 Window.moveWindowTo() 就可以实时更新悬浮窗的位置了。

kotlin 复制代码
updateLocation(offSetX: number, offsetY: number) {
    if (this.sub_windowClass != null) {
        this.locationX = this.locationX + offSetX
        this.locationY = this.locationY + offsetY
        this.sub_windowClass.moveWindowTo(this.locationX, this.locationY, (err: BusinessError) => {
            ......
        });
    }
}

在点击手势 TapGesture中,我的需求是路由到指定页面,直接调用 router.pushUrl()。看似很正常的调用,在这里确得到了意想不到的结果。

发生页面跳转的并不是预期中的应用主窗口,而是应用子窗口。

把问题抛到群里之后,得到了群友的热心解答。

每个 Window 对应自己的 UIContext,UIContext 持有自己的 Router ,所以应用主窗口和应用子窗口的 Router 是相互独立的。

那么,问题就变成了如何在子窗口中让主窗口进行路由跳转?通过 EventHub 或者 emitter 都可以。emiiter 可以跨线程,这里并不需要,EventHub 写起来更简单。我们在点击手势中发送事件:

scss 复制代码
TapGesture({ count: 1 })
  .onAction(() => {
      this.context.eventHub.emit("clickFloat")
  })

EntryAbility 中订阅事件:

dart 复制代码
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    eventHub.on("clickFloat", () => {
      if (this.mainRouter) {
        this.mainRouter.pushUrl(...)
      }
    })
}

这里的 mainRouter 我们可以提前在主 Window 调用 loadContent() 之后获取:

ini 复制代码
windowStage.loadContent(pages/Index', (err, data) => {
  this.mainRouter = this.windowClass!.getUIContext().getRouter()
});

最后还有一个小细节,如果在拖动悬浮窗之后,再使用系统的返回手势,按照预期应该是主窗口的页面返回,但这时候焦点在子窗口,主窗口并不会响应返回手势。

我们需要在子窗口承载的 Page 页面监听 onBackPress(),并通过 EventHub 通知主窗口。

arduino 复制代码
  onBackPress(): boolean | void {
    this.context.eventHub.emit("float_back")
  }

主窗口接收到通知后,调用 mainRouter.back 。

kotlin 复制代码
eventHub.on("clickFloat", () => {
  if (this.mainRouter) {
    this.mainRouter.back()
  }
})

应用内全局,可拖拽的悬浮窗就完成了。

代码在这里:FloatManager

最后

从一位老安卓开发的视角,经历了 Google 对 Android 这么多年的缝缝补补,而鸿蒙作为一款去年才开始真正发力的操作系统,除了给用户带来良好的使用体验以外,给开发者带来良好的开发体验也是至关重要的。希望鸿蒙越来越好吧!

相关推荐
A黄俊辉A2 小时前
webstorm+vue+esLint+pretter配置
前端·vue.js·webstorm
TYFHVB122 小时前
2026六大主流CRM横评,五大核心维度深度解析
大数据·前端·数据结构·人工智能
LiuMingXin2 小时前
断网也能装包? 我在物理隔离内网搭了一套完整的私有npm仓库
前端·面试·npm
一直向钱2 小时前
android stutio 安装Ai插件写代码
android
CHU7290352 小时前
趣味抽赏,解锁惊喜——扭蛋机盲盒抽赏小程序前端功能解析
前端·小程序
开开心心就好3 小时前
Word批量转PDF工具,仅转换不合并很实用
java·前端·人工智能·edge·pdf·语音识别·模块测试
孫治AllenSun3 小时前
【redis】redis重新创建集群
前端·javascript·redis
linchare3 小时前
[原创]如何排查php-fpm的502报错(SIGSEGV)
android
卡皮巴拉c993 小时前
基于 wujie.js 进行微前端融合
前端
来碗疙瘩汤3 小时前
深入解析 Vue 包:`vue` 究竟导出了什么?
前端