HarmonyOS 鸿蒙Next中如何实现悬浮球

HarmonyOS 鸿蒙Next中如何实现悬浮球 在Android上能够利用弹窗或者自定义view去实现悬浮球效果,那鸿蒙上该如何实现这块呢?有参考的demo吗?

5 回复

如果想跨页面则建议用overlayManager去实现该功能

悬浮窗触摸核心代码:

import { curves } from '@kit.ArkUI';
import display from '@ohos.display';

export class Constants {
  static readonly PAGE_PADDING: number = 12; // 页面内容内边距,用于悬浮窗位置计算
  static readonly FLOAT_WINDOW_WIDTH: number = 50; // 悬浮窗宽度
  static readonly FLOAT_WINDOW_HEIGHT: number = 50; // 悬浮窗高度
  static readonly INIT_POSITION_Y: number = 300; // 悬浮窗相对父容器左上角的Y坐标初始值
}

/**
 * 使用悬浮窗组件样例
 *
 * 核心组件:
 * 1. FloatWindowView
 *
 * 实现步骤:
 *  1. 悬浮窗组件使用Stack嵌套其他布局实现,使用属性position绝对定位使组件悬浮位置
 *  2. 初始化时悬浮窗的position属性设置x和y,让悬浮窗靠右
 *  3. 在渲染前获取屏幕的宽高(使用overlayManager承载时,悬浮窗的坐标是从屏幕最中间开始)
 *  4. 悬浮窗组件添加onTouchEvent回调,在手指按下时保存触摸点在窗口中的坐标,用于移动时悬浮窗位置的计算
 *  5. 手指移动时,获取触摸点相对于应用窗口左上角的X和Y坐标,通过计算设置悬浮窗的position坐标实现拖拽,使用默认参数的弹性跟手动画曲线curves.responsiveSpringMotion结合animateTo实现跟手动画效果
 *  6. 手指抬起时,通过判断悬浮窗中心在水平方向位于父组件中心的左侧或右侧设置悬浮窗靠左或靠右,如果悬浮窗超出内容区上下边界,则将悬浮窗设置在边界位置,使用curves.springMotion弹性动画曲线实现吸附边界时的弹性动画效果
 */
@Component
export struct FloatWindowMainPage {
  @State screenWidth: number = 0;
  @State screenHeight: number = 0;

  build() {
    Stack({ alignContent: Alignment.TopEnd }) {
      // 悬浮窗视图必须是顶层显示
      FloatWindowView({ containerWidth: this.screenWidth, containerHeight: this.screenHeight })
    }
    .width(Constants.FLOAT_WINDOW_WIDTH)
    .height(Constants.FLOAT_WINDOW_HEIGHT)
  }

  aboutToAppear(): void {
    let data = display.getDefaultDisplaySync();
    this.screenWidth = px2vp(data.width);
    this.screenHeight = px2vp(data.height);
  }
}

@Component
struct FloatWindowView {
  // 悬浮窗相对于父组件位置信息
  @State pos: Position = { y: Constants.INIT_POSITION_Y, x: Constants.PAGE_PADDING };
  @Link containerWidth: number;
  @Link containerHeight: number;
  // 拖拽移动开始时悬浮窗在窗口中的坐标,每次移动回调触发时更新
  private windowStartX: number = 0;
  private windowStartY: number = 0;

  aboutToAppear(): void {
    this.pos.y = Constants.INIT_POSITION_Y - this.containerHeight / 2
    this.pos.x = Constants.PAGE_PADDING + Constants.FLOAT_WINDOW_WIDTH / 2 - this.containerWidth / 2
  }

  /**
   * 触摸回调,悬浮窗跟手和贴边动画
   */
  onTouchEvent(event: TouchEvent): void {
    switch (event.type) {
      case TouchType.Down: {
        // 获取拖拽开始时悬浮窗在窗口中的坐标
        this.windowStartX = event.touches[0].screenX;
        this.windowStartY = event.touches[0].screenY;
        break;
      }
      case TouchType.Move: {
        const windowX: number = event.touches[0].screenX;
        const windowY: number = event.touches[0].screenY;
        // TODO:知识点:跟手动画,推荐使用默认参数的弹性跟手动画曲线curves.responsiveSpringMotion。
        animateTo({ curve: curves.responsiveSpringMotion() }, () => {
          // 控制悬浮窗坐标的位置
          this.pos.x = this.pos.x as number + (windowX - this.windowStartX);
          this.pos.y = this.pos.y as number + (windowY - this.windowStartY);
          this.windowStartX = windowX;
          this.windowStartY = windowY;
        })
        break;
      }
      case TouchType.Up: {
        // TODO:知识点:通过判断悬浮窗在父组件中的位置,设置悬浮窗贴边,使用curves.springMotion()弹性动画曲线,可以实现阻尼动画效果
        animateTo({ curve: curves.springMotion() }, () => {
          // // 判断悬浮窗中心在水平方向是距离中心点位置,根据结果设置靠左或靠右
          if (this.pos.x as number > 0) {
            this.pos.x = this.containerWidth / 2 - Constants.PAGE_PADDING - Constants.FLOAT_WINDOW_WIDTH / 2;
          } else {
            this.pos.x =  Constants.PAGE_PADDING + Constants.FLOAT_WINDOW_WIDTH / 2 - this.containerWidth / 2 ;
          }
          // 判断悬浮窗是否超出内容区上下边界,根据结果将悬浮窗设置在边界位置
          if (this.pos.y as number < Constants.PAGE_PADDING - this.containerHeight / 2 + Constants.FLOAT_WINDOW_HEIGHT / 2) {
            this.pos.y = Constants.PAGE_PADDING - this.containerHeight / 2 + Constants.FLOAT_WINDOW_HEIGHT / 2;
          } else if (this.pos.y as number >
            this.containerHeight / 2 - Constants.FLOAT_WINDOW_HEIGHT / 2 - Constants.PAGE_PADDING) {
            this.pos.y = this.containerHeight / 2 - Constants.FLOAT_WINDOW_HEIGHT - Constants.PAGE_PADDING -
              Constants.FLOAT_WINDOW_HEIGHT / 2;
          }
        })
        break;
      }
      default: {
        break;
      }
    }
  }

  build() {
    Image($r('app.media.startIcon'))
      .clip(true)
      .borderRadius(25)
      .width(Constants.FLOAT_WINDOW_WIDTH)
      .height(Constants.FLOAT_WINDOW_HEIGHT)
      .position(this.pos)
      .onClick(() => {
      })
      .onTouch((event: TouchEvent) => {
        this.onTouchEvent(event);
      })
  }
}

主界面使用代码:

import { ComponentContent, OverlayManager } from '@kit.ArkUI';
import { FloatWindowMainPage } from '../FloatWindowMainPage';

class Params {
  text: string = "";
  offset: Position;

  constructor(text: string, offset: Position) {
    this.text = text;
    this.offset = offset;
  }
}

@Builder
function builderText(params: Params) {
  FloatWindowMainPage()
    .height(50)
    .width(50)
}

@Entry
@Component
struct OverlayExample {
  @State message: string = 'ComponentContent';
  private uiContext: UIContext = this.getUIContext();
  private overlayNode: OverlayManager = this.uiContext.getOverlayManager();
  componentContent = new ComponentContent(
    this.uiContext, wrapBuilder<[Params]>(builderText),
    new Params(this.message, { x: 0, y: 0 })
  );
  build() {
    Column() {
      Button("添加悬浮球").onClick(() => {
        this.overlayNode.addComponentContent(this.componentContent,0);
      })
      Button("删除悬浮球" ).onClick(() => {
        this.overlayNode.removeComponentContent(this.componentContent);
      })
      Button("显示悬浮球" ).onClick(() => {
        this.overlayNode.showComponentContent(this.componentContent);
      })
      Button("隐藏悬浮球").onClick(() => {
        this.overlayNode.hideComponentContent(this.componentContent);
      })
    }
    .width('100%')
    .height('100%')
  }
}

该实现好处是跨页面展示也可以展示之前的悬浮窗

更多关于HarmonyOS 鸿蒙Next中如何实现悬浮球的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


开发者你好,可以参考如下代码进行实现:

核心:

import { curves } from '@kit.ArkUI';

export class Constants {
  static readonly PAGE_PADDING: number = 12; // 页面内容内边距,用于悬浮窗位置计算
  static readonly FLOAT_WINDOW_WIDTH: number = 90; // 悬浮窗宽度
  static readonly FLOAT_WINDOW_HEIGHT: number = 90; // 悬浮窗高度
  static readonly INIT_POSITION_Y: number = 300; // 悬浮窗相对父容器左上角的Y坐标初始值
}
/**
 * 使用悬浮窗组件样例
 *
 * 核心组件:
 * 1. FloatWindowView
 *
 * 实现步骤:
 *  1. 悬浮窗组件使用Stack嵌套其他布局实现,使用属性position绝对定位使组件悬浮,position使用Edges类型控制悬浮窗到父组件四条边的距离
 *  2. 初始化时悬浮窗的position属性设置top和right,让悬浮窗靠右
 *  3. 父组件添加onAreaChange回调,获取父组件的宽高
 *  4. 悬浮窗组件添加onTouchEvent回调,在手指按下时保存触摸点在窗口中的坐标,用于移动时悬浮窗位置的计算
 *  5. 手指移动时,获取触摸点相对于应用窗口左上角的X和Y坐标,通过计算设置悬浮窗的position坐标实现拖拽,使用默认参数的弹性跟手动画曲线curves.responsiveSpringMotion结合animateTo实现跟手动画效果
 *  6. 手指抬起时,通过判断悬浮窗中心在水平方向位于父组件中心的左侧或右侧设置悬浮窗靠左或靠右,如果悬浮窗超出内容区上下边界,则将悬浮窗设置在边界位置,使用curves.springMotion弹性动画曲线实现吸附边界时的弹性动画效果
 */
@Component
export struct FloatWindowMainPage {
  private scroller: Scroller = new Scroller();
  // 父组件宽度
  @State containerWidth: number = 0;
  // 父组件高度
  @State containerHeight: number = 0;
  build() {
    Stack({ alignContent: Alignment.TopEnd }) {
      // 以column作为悬浮窗视图下面的内容区,由开发者自行实现
      Column().width('100%').height('100%')
      // 悬浮窗视图必须是顶层显示
      FloatWindowView({ containerWidth: this.containerWidth, containerHeight: this.containerHeight })
    }
    .width('100%')
    .height('100%')
    .onAreaChange(( oldValue: Area, newValue: Area ) => {
      // TODO:性能知识点:onAreaChange是高频回调,仅在父组件尺寸改变时获取新的父组件宽高,避免性能损耗
      if (oldValue.width !== newValue.width) {
        this.containerWidth = newValue.width as number;
      }
      if (oldValue.height !== newValue.height) {
        this.containerHeight = newValue.height as number;
      }
    })
  }
}

@Component
struct FloatWindowView {
  // 悬浮窗相对于父组件四条边的距离,top和bottom同时设置时top生效,right和left同时设置时left生效
  @State edge: Edges = { top: Constants.INIT_POSITION_Y, right: Constants.PAGE_PADDING };
  @Link containerWidth: number;
  @Link containerHeight: number;
  // 拖拽移动开始时悬浮窗在窗口中的坐标,每次移动回调触发时更新
  private windowStartX: number = 0;
  private windowStartY: number = 0;

  /**
   * 触摸回调,悬浮窗跟手和贴边动画
   */
  onTouchEvent(event: TouchEvent): void {
    switch (event.type) {
      case TouchType.Down: {
        // 获取拖拽开始时悬浮窗在窗口中的坐标
        this.windowStartX = event.touches[0].windowX;
        this.windowStartY = event.touches[0].windowY;
        break;
      }
      case TouchType.Move: {
        const windowX: number = event.touches[0].windowX;
        const windowY: number = event.touches[0].windowY;
        // TODO:知识点:跟手动画,推荐使用默认参数的弹性跟手动画曲线curves.responsiveSpringMotion。
        animateTo({ curve: curves.responsiveSpringMotion() }, () => {
          // 判断当前edge中属性left和right哪个不为undefined,用于控制悬浮窗水平方向的位置
          if (this.edge.left !== undefined) {
            this.edge.left = this.edge.left as number + (windowX - this.windowStartX);
          } else {
            this.edge.right = this.edge.right as number - (windowX - this.windowStartX);
          }
          this.edge.top = this.edge.top as number + (windowY - this.windowStartY);
          this.windowStartX = windowX;
          this.windowStartY = windowY;
        })
        break;
      }
      case TouchType.Up: {
        // 计算悬浮窗中心点在父组件中水平方向的坐标
        let centerX: number;
        if (this.edge.left !== undefined) {
          centerX = this.edge.left as number + Constants.FLOAT_WINDOW_WIDTH / 2;
        } else {
          centerX = this.containerWidth - (this.edge.right as number) - Constants.FLOAT_WINDOW_WIDTH / 2;
        }
        // TODO:知识点:通过判断悬浮窗在父组件中的位置,设置悬浮窗贴边,使用curves.springMotion()弹性动画曲线,可以实现阻尼动画效果
        animateTo({ curve: curves.springMotion() }, () => {
          // 判断悬浮窗中心在水平方向是否超过父组件宽度的一半,根据结果设置靠左或靠右(如果不想要左右吸附效果,则将edge的left和right设置逻辑去除)
          if (centerX > (this.containerWidth / 2)) {
            this.edge.right = Constants.PAGE_PADDING;
            this.edge.left = undefined;
          } else {
            this.edge.right = undefined;
            this.edge.left = Constants.PAGE_PADDING;
          }
          // 判断悬浮窗是否超出内容区上下边界,根据结果将悬浮窗设置在边界位置
          if (this.edge.top as number < Constants.PAGE_PADDING) {
            this.edge.top = Constants.PAGE_PADDING;
          } else if (this.edge.top as number >
            this.containerHeight - Constants.FLOAT_WINDOW_HEIGHT - Constants.PAGE_PADDING) {
            this.edge.top = this.containerHeight - Constants.FLOAT_WINDOW_HEIGHT - Constants.PAGE_PADDING;
          }
        })
        break;
      }
      default: {
        break;
      }
    }
  }

  build() {
    Image($r('app.media.startIcon'))
    .clip(true)
    .borderRadius(25)
    .width(50)
    .height(50)
    .position(this.edge)
    .onClick(() =>{
    })
    .onTouch((event: TouchEvent) => {
      this.onTouchEvent(event);
    })
  }
}

主页实现:

import { FloatWindowMainPage } from "../FloatWindowMainPage";

@Component
@Entry
export struct FloatWindowMainPageComponent {
  build() {
    Column() {
      FloatWindowMainPage()
        .height("100%")
        .width("100%")
    }.width('100%').height('100%')
  }
}

其核心思路是借鉴 https://gitee.com/harmonyos-cases/cases/blob/master/CommonAppDevelopment/feature/floatwindow/README.md

希望能帮到您

在HarmonyOS鸿蒙Next中实现悬浮球,可以通过使用WindowManagerWindowManager.LayoutParams来创建和管理悬浮窗口。首先,创建一个WindowManager实例,然后定义WindowManager.LayoutParams来设置窗口的类型、位置和大小。接着,使用WindowManageraddView方法将自定义的悬浮球视图添加到窗口中。通过监听触摸事件,可以实现悬浮球的拖动功能。最后,确保在应用权限中申请SYSTEM_ALERT_WINDOW权限,以允许应用显示悬浮窗口。

在HarmonyOS Next中实现悬浮球效果,可以通过以下方式实现:

  1. 使用WindowManagerService创建悬浮窗:
  • 通过WindowManager.addWindow()方法添加悬浮窗口
  • 设置WindowType为TYPE_SYSTEM_ALERT
  • 配置LayoutConfig为悬浮窗参数
  1. 关键实现步骤:
  • 创建AbilitySlice作为悬浮球容器
  • 设置窗口属性为悬浮模式:
let windowClass = new window.Window(this.context)
windowClass.setWindowType(window.WindowType.TYPE_SYSTEM_ALERT)
windowClass.setLayoutConfig({x:0, y:0, width:100, height:100})
  1. 触摸事件处理:
  • 重写onTouchEvent实现拖动效果
  • 使用window.moveTo()方法更新悬浮球位置
  1. 权限申请:
  • 需要在config.json中声明ohos.permission.SYSTEM_FLOAT_WINDOW权限

相比Android的实现,HarmonyOS的悬浮窗管理更加规范,需要严格遵循系统窗口管理规范。目前官方文档中有窗口管理的详细示例,可以参考WindowManager相关API文档。

注意:HarmonyOS Next对悬浮窗的管理策略可能会随版本更新而变化,建议保持对最新API文档的关注。

回到顶部