鸿蒙实现全局悬浮工具球 原创

auhgnixgnahz
发布于 2025-9-22 09:27
浏览
0收藏

实现一个全局可随意拖动的悬浮工具球,可控制是否吸边,根据悬浮球的位置,向四个方向弹出工具栏。全局监听工具按钮的点击事件回调。
效果演示:
鸿蒙实现全局悬浮工具球-鸿蒙开发者社区
实现思路:
1.使用新建窗口实现悬浮状态,收起状态时系统窗口和控制按钮大小一样,当展开时,计算需要的窗口大小,重新设置悬浮窗口大小
2.控制是否吸边,只需要在拖动手势结束时,判断当前手指位置在屏幕的左侧还是右侧,然后将窗口的X坐标赋值成0或屏幕像素-窗口大小,避免遮挡
3.将屏幕划分为四个区域,上左上右下左下右,操作按钮在不同的区域内,向不同的方向弹出工具栏
4.窗口大小发生变化,操作按钮相对于窗口的位置也会发生变化,因此需要在展开时重新计算操作按钮相当于窗口的位置
5.X轴使用Stack布局存放所有按键,Alignment设置的是底部居左,当在左半屏展开时,不需要修改X偏移,在右半屏展开时,需要将窗口向左移动操作按键相对于窗口向右偏移。这样就可以保证展开时操作按钮在X轴方向没有移动
6.Y轴,当在上半屏展开时,想要工具栏向下展开,因此操作按钮相对于Y轴要向上移动,当在下半屏展开时,想要工具栏向上展开,因此需要将窗口向上移动,这样就保证展开时操作按钮相对于Y轴没有移动。
7.拖动手势,只需要在拖动的过程中实时修改窗口位置就可以实现拖动效果
8.单击手势,点击展开时,需要计算每个弹出工具栏的位置,这里可以参考上一篇文章,使用三角函数计算每个按键的X,Y偏移量
9.点击回调,由于点击回调可能接收的位置是任意一个page页面,因此这里使用eventHub事件通信机制,只需要在想要接收回调的地方订阅即可。
10.具体实现步骤可以参考源码注释
悬浮窗源码

import { WindowUtils } from '../utils/WindowUtils';
export const FLOAT_WINDOW_DEFAUT_SIZE=40
export const EVENT_CLICK_MESSAGE:string='EVENT_CLICK_MESSAGE'
const FLOAT_WINDOW_ICON_SIZE=40
@Entry
@ComponentV2
struct FloatToolBar{
  private  isEdgeNear:boolean = AppStorage.get('isEdgeNear')??false // 是否贴边
  @Local  normalIconSpace:number = 120 // 控制按钮和弹出按钮的间距
  private  toolCounts:number = 4 //默认弹出4个按钮
  private  spaceAngle:number = 5 //距离XY轴的夹角
  @Local floatOffsetX:number=0;
  @Local floatOffsetY:number=0; // 控制 操作按钮 相对于 窗口的位置
  @Local fingerClickX:number=0; //点击时 手指相对于屏幕的位置
  @Local fingerClickY:number=WindowUtils.getWindowHeight()*2/3;
  @Local posX:number = 0
  @Local posY:number = WindowUtils.getWindowHeight()*2/3
  @Local spaceBetween: number = 0
  @Local iconOpacity: number = 0
  @Local isExpend:boolean = false
  aboutToAppear(): void {

  }
  calculteOffset(index: number): Position {
    if (this.isExpend) {
      //每个按钮之间的夹角
      let gapAngle = (90-2*this.spaceAngle)/(this.toolCounts-1)
      let angleRadian: number =0
      //右上展开
      if (vp2px(this.fingerClickX)<WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)>WindowUtils.getWindowHeight()/2){
        angleRadian =( 360-(90-this.spaceAngle-index*gapAngle)) * Math.PI / 180
      }
      //左上
      if (vp2px(this.fingerClickX)>WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)>WindowUtils.getWindowHeight()/2){
        angleRadian = ( 180+ (90-this.spaceAngle-index*gapAngle)) * Math.PI / 180
      }
      //右下
      if (vp2px(this.fingerClickX)<WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)<WindowUtils.getWindowHeight()/2){
        angleRadian = ((90-this.spaceAngle-index*gapAngle)) * Math.PI / 180
      }
      //左下
      if (vp2px(this.fingerClickX)>WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)<WindowUtils.getWindowHeight()/2){
        angleRadian = ( 90 + (90-this.spaceAngle-index*gapAngle)) * Math.PI / 180
      }

      return {
        x: this.floatOffsetX+this.spaceBetween * Math.cos(angleRadian),
        y: this.floatOffsetY+this.spaceBetween * Math.sin(angleRadian)
      }
    }else {
      //右上
      if (vp2px(this.fingerClickX)<WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)>WindowUtils.getWindowHeight()/2){
        return {
          x: 0,
          y: 0
        }
      }
      //左上
      if (vp2px(this.fingerClickX)>WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)>WindowUtils.getWindowHeight()/2){
        return {
          x: this.normalIconSpace,
          y: 0
        }
      }
      //右下
      if (vp2px(this.fingerClickX)<WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)<WindowUtils.getWindowHeight()/2){
        return {
          x: 0,
          y: -this.normalIconSpace
        }
      }
      //左下
      if (vp2px(this.fingerClickX)>WindowUtils.getWindowWidth()/2&&vp2px(this.fingerClickY)<WindowUtils.getWindowHeight()/2){
        return {
          x: this.normalIconSpace,
          y: -this.normalIconSpace
        }
      }
      return {
        x: 0,
        y: 0
      }
    }
  }

  build() {
    Stack({alignContent:Alignment.BottomStart}){
      Circle()
        .width(FLOAT_WINDOW_ICON_SIZE)
        .height(FLOAT_WINDOW_ICON_SIZE)
        .fill(Color.Green)
        .fillOpacity(this.iconOpacity)
        .offset(this.calculteOffset(0))
        .animation({
          duration: 300,
          curve:'ease-out'
        })
        .onClick(()=>{
          WindowUtils.getUIAbilityContext()?.eventHub.emit('EVENT_CLICK_MESSAGE',Color.Green );
        })
      Circle()
        .width(FLOAT_WINDOW_ICON_SIZE)
        .height(FLOAT_WINDOW_ICON_SIZE)
        .fill(Color.Brown)
        .fillOpacity(this.iconOpacity)
        .offset(this.calculteOffset(1))
        .animation({
          duration: 300,
          curve:'ease-out'
        })
        .onClick(()=>{
          WindowUtils.getUIAbilityContext()?.eventHub.emit('EVENT_CLICK_MESSAGE',Color.Brown );
        })
      Circle()
        .width(FLOAT_WINDOW_ICON_SIZE)
        .height(FLOAT_WINDOW_ICON_SIZE)
        .fill(Color.Red)
        .fillOpacity(this.iconOpacity)
        .offset(this.calculteOffset(2))
        .animation({
          duration: 300,
          curve:'ease-out'
        })
        .onClick(()=>{
          WindowUtils.getUIAbilityContext()?.eventHub.emit('EVENT_CLICK_MESSAGE',Color.Red );
        })
      Circle()
        .width(FLOAT_WINDOW_ICON_SIZE)
        .height(FLOAT_WINDOW_ICON_SIZE)
        .fill(Color.Yellow)
        .fillOpacity(this.iconOpacity)
        .offset(this.calculteOffset(3))
        .animation({
          duration: 300,
          curve:'ease-out'
        })
        .onClick(()=>{
          WindowUtils.getUIAbilityContext()?.eventHub.emit('EVENT_CLICK_MESSAGE',Color.Yellow );
        })

      Circle()
        .width(FLOAT_WINDOW_ICON_SIZE)
        .height(FLOAT_WINDOW_ICON_SIZE)
        .fill(Color.Blue)
        .fillOpacity(1)
        .zIndex(5)
        .offset({
          x:this.floatOffsetX,
          y:this.floatOffsetY
        })
        .gesture(
          GestureGroup(GestureMode.Exclusive,
            TapGesture({ count: 1, fingers: 1 })
              .onAction((event: GestureEvent) => {
                this.fingerClickX=event.fingerList[0].displayX
                this.fingerClickY=event.fingerList[0].displayY
                if (this.isExpend) {
                  if (this.fingerClickX>px2vp(WindowUtils.getWindowWidth()/2)) {
                    this.posX = this.posX+vp2px(this.spaceBetween)
                    this.floatOffsetX=0
                  }
                  if (this.fingerClickY<px2vp(WindowUtils.getWindowHeight()/2)) {
                    this.floatOffsetY = 0
                    this.posY = this.posY - vp2px(this.spaceBetween)
                  }
                  WindowUtils.subWindow?.resize(vp2px(FLOAT_WINDOW_ICON_SIZE),vp2px(FLOAT_WINDOW_ICON_SIZE))
                  this.posY = this.posY + vp2px(this.spaceBetween)
                  WindowUtils.subWindow?.moveWindowTo(this.posX ,this.posY)
                  this.isExpend = !this.isExpend
                  this.spaceBetween=0
                  this.iconOpacity=0
                }else {
                  this.spaceBetween=this.normalIconSpace
                  this.iconOpacity=0.8
                  //位于左半屏 向右展开,不需要额外操作 位于右半屏时,向左展开
                  if (this.fingerClickX>px2vp(WindowUtils.getWindowWidth()/2)) {
                    this.posX = this.posX-vp2px(this.spaceBetween)
                    this.floatOffsetX = this.spaceBetween
                  }
                  //位于上半屏,向下展开
                  if (this.fingerClickY<px2vp(WindowUtils.getWindowHeight()/2)) {
                    this.floatOffsetY = -this.spaceBetween
                  }else {
                    this.posY = this.posY - vp2px(this.spaceBetween)
                  }
                  WindowUtils.subWindow?.resize(vp2px(this.spaceBetween+FLOAT_WINDOW_ICON_SIZE),vp2px(this.spaceBetween+FLOAT_WINDOW_ICON_SIZE))
                  WindowUtils.subWindow?.moveWindowTo(this.posX ,this.posY)
                  this.isExpend = !this.isExpend
                }

              }),
            PanGesture()
              .onActionStart( (event: GestureEvent) => {
                this.normalIconSpace=0
              })
              .onActionUpdate((event: GestureEvent) => {
                console.info('Pan start'+event.offsetX);
                if (this.isExpend) return
                this.fingerClickX=event.fingerList[0].displayX
                this.fingerClickY=event.fingerList[0].displayY
                this.posX += event.offsetX
                this.posY += event.offsetY
                //顶部限制
                if (this.posY<WindowUtils.getStatusHeight()) {
                  this.posY = WindowUtils.getStatusHeight()
                }
                //底部限制
                if (this.posY>WindowUtils.getWindowHeight()-WindowUtils.getNavHeight()-vp2px(FLOAT_WINDOW_DEFAUT_SIZE)) {
                  this.posY = WindowUtils.getWindowHeight()-WindowUtils.getNavHeight()-vp2px(FLOAT_WINDOW_DEFAUT_SIZE)
                }
                //左边限制
                if (this.posX<0) {
                  this.posX=0
                }
                if (this.posX>WindowUtils.getWindowWidth()-vp2px(FLOAT_WINDOW_DEFAUT_SIZE)) {
                  this.posX = WindowUtils.getWindowWidth()-vp2px(FLOAT_WINDOW_DEFAUT_SIZE)
                }
                WindowUtils.subWindow?.moveWindowTo(this.posX ,this.posY)
              })
              .onActionEnd(()=>{
                this.normalIconSpace=120
                //判断是否贴边,如果开启贴边的话,结束拖拽判断是在屏幕最右还是最左
                if (this.isEdgeNear) {
                  if (this.posX>WindowUtils.getWindowWidth()/2) {
                    this.posX = WindowUtils.getWindowWidth()-vp2px(FLOAT_WINDOW_DEFAUT_SIZE)
                  }else {
                    this.posX=0
                  }
                  WindowUtils.subWindow?.moveWindowTo(this.posX ,this.posY)
                }
              })
          )
        )

    }.width('100%').height('100%')
  }
}

调用源码

import { EVENT_CLICK_MESSAGE, FLOAT_WINDOW_DEFAUT_SIZE } from '../pages/FloatToolBar'
import { WindowUtils } from '../utils/WindowUtils'
@Entry
@ComponentV2
struct TestFloatToolBar{
  @Local receiveMessage:string='#00000000'
  aboutToAppear(): void {
    WindowUtils.getUIAbilityContext()?.eventHub.on(EVENT_CLICK_MESSAGE,(callBack:string)=>{
      this.receiveMessage = callBack
    })
  }
  build() {
    Column({space:10}){
      Button('打开任意位置悬浮框').onClick(()=>{
        AppStorage.setOrCreate('isEdgeNear',false)
        WindowUtils.openSubWindow('pages/FloatToolBar',{
          height:vp2px(FLOAT_WINDOW_DEFAUT_SIZE),
          width:vp2px(FLOAT_WINDOW_DEFAUT_SIZE),
          y:WindowUtils.getWindowHeight()*2/3
        })
      })
      Button('打开贴边悬浮框').onClick(()=>{
        AppStorage.setOrCreate('isEdgeNear',true)
        WindowUtils.openSubWindow('pages/FloatToolBar',{
          height:vp2px(FLOAT_WINDOW_DEFAUT_SIZE),
          width:vp2px(FLOAT_WINDOW_DEFAUT_SIZE),
          y:WindowUtils.getWindowHeight()*2/3
        })
      })
      Button('关闭悬浮窗').onClick(()=>{
       WindowUtils.closeSubWindow()
      })

      Row(){
        Text('点击了')
        Row().width('30%').height(30).backgroundColor(this.receiveMessage)
      }
    }
  }
}

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
收藏
回复
举报
回复
    相关推荐