HarmonyOS 悬浮球实战:从页面内组件到 SubWindow 方案

HarmonyOS 悬浮球实战:从页面内组件到 SubWindow 方案

    • 一、需求整理------从页面内组件探索悬浮控件
    • [二、抽离通用组件 `FloatingToolbox`](#二、抽离通用组件 FloatingToolbox)
      • [1. 默认必须收起,不要一上来就展开](#1. 默认必须收起,不要一上来就展开)
      • [2. 点击和拖动必须分开判断](#2. 点击和拖动必须分开判断)
      • [3. 小球不能乱拖,必须有边界意识](#3. 小球不能乱拖,必须有边界意识)
      • [4. 松手必须吸边,不要把球留在屏幕中间当路障](#4. 松手必须吸边,不要把球留在屏幕中间当路障)
      • [5. 展开方向一定要跟随停靠边](#5. 展开方向一定要跟随停靠边)
    • [三、悬浮球展开面板------ `Stack` 叠层改造](#三、悬浮球展开面板—— Stack 叠层改造)
      • [1. `@Builder` 不是组件实例,别直接链式调用属性](#1. @Builder 不是组件实例,别直接链式调用属性)
      • [2. 只要元素会伸出容器,就要第一时间想到 `.clip(false)`](#2. 只要元素会伸出容器,就要第一时间想到 .clip(false))
      • [3. 很多布局问题不是"像素不对",而是"结构理解错了"](#3. 很多布局问题不是“像素不对”,而是“结构理解错了”)
    • [四、`SubWindow` 方案实现全局悬浮球](#四、SubWindow 方案实现全局悬浮球)
      • [理解ArkUI 子窗口方案的主从关系](#理解ArkUI 子窗口方案的主从关系)
      • [页面内方案和 `SubWindow` 方案,如何做工程决策](#页面内方案和 SubWindow 方案,如何做工程决策)
    • 结语

摘要:很多 HarmonyOS 项目在完成 HDS 悬浮页签、沉浸光感之后,页面已经足够"轻"和"系统",但高频快捷入口依然缺一个顺手的落点。本文结合一次真实改造过程,讲清楚我们为什么先做页面内悬浮球、它到底解决了什么问题、实现里最关键的交互细节是什么、踩了哪些典型 ArkUI 坑,以及如果后续想升级成应用内全局悬浮球,SubWindow 方案应该怎么理解主从关系。

如果你最近在做 HarmonyOS 首页改造,尤其已经在接入这些能力:

  • HDS 悬浮页签
  • 沉浸光感材质
  • miniBar 轻信息区
  • 更沉浸的内容布局

你很快会碰到一个很现实的问题:

主导航已经够满了,但业务还想再塞几个高频动作。

这类动作通常都很典型:

  • 返回顶部
  • 快速定位
  • 打开某个常用面板
  • 一键触发某个安全或工具能力
  • 在内容流中保留一个稳定可见的快捷入口

问题在于,这些能力都很常用,但又不太适合直接放进主导航。

放到底部 Tab,太重。

放到右上角菜单,太深。

做成普通按钮,太硬。

所以这次改造里,我们最终选的是一个更符合这类需求气质的方案:

悬浮球。

但本文要讲的,不是跨应用的系统级悬浮窗,而是一个更适合业务项目先落地的版本:

页面内悬浮球。

一、需求整理------从页面内组件探索悬浮控件

这次改造开始前,我们手里其实已经有一份参考方案,核心思路是用 ArkUI 的 SubWindow 做应用内悬浮球。

那套方案的能力非常完整,适合这些场景:

  • 悬浮球需要跨多个页面持续存在
  • 希望它和当前页面布局进一步解耦
  • 需要更接近"应用内全局悬浮"的效果
  • 后续还会持续扩展更多动作和状态

从能力角度看,SubWindow 当然更强。

但我们当时的实际需求没有那么重。

更准确地说,我们眼前要解决的,是一个首页快捷入口问题,而不是一个窗口管理问题。那时更重要的是:

  1. 先在现有首页里快速落地
  2. 先验证交互是不是顺手
  3. 不改大结构,不补整套窗口生命周期
  4. 尽量复用当前工程已有资源和页面结构

于是我们最终做了一个很重要的工程判断:

第一版先做页面内悬浮球。

这个判断的价值在于,它让我们把问题压缩成了一个更适合快速验证的范围:

  • 一个独立组件
  • 一处顶层挂载
  • 一套相对轻量但完整的交互模型

这不是"做简单版",而是先做"最值得做的版本"。

如果把需求压缩成一句话,它不是"做一个悬浮控件",而是:

做一个用户不觉得烦、页面不觉得乱、工程也不觉得重的悬浮球。

拆开之后,目标其实非常明确:

  • 默认收起为小球,不是默认展开
  • 小球支持拖动
  • 松手之后自动吸附左右边缘
  • 点击小球展开快捷面板
  • 面板展开方向要跟随停靠边,始终朝屏幕内侧打开
  • 要避开顶部安全区和底部 HDS 悬浮页签
  • 尽量做成一个独立组件,接到现有页面里就能用

这一版里,我们不追求一开始就把能力做到"最全",而是先把下面几件事做对:

  • 收起态是否轻量
  • 拖动是否顺滑
  • 点击和拖动是否冲突
  • 展开后内容有没有被挤占
  • 组件有没有破坏现有页面交互

这些问题如果没做顺,哪怕你用了更重的方案,最后用户感受到的也还是"不好用"。


二、抽离通用组件 FloatingToolbox

这次方案最终落得很克制。

页面层只做一件事:挂载。

组件层负责一切交互和 UI。

也就是说,Main.ets 不负责悬浮球的状态管理,它只负责告诉组件:

  • 你现在处在一个什么页面里
  • 你底部需要避开什么区域

挂载代码非常直接:

ts 复制代码
FloatingToolbox({
  bottomInset: HDS_BAR_HEIGHT + HDS_BAR_BOTTOM_MARGIN * 2
})

这里的 bottomInset 不是随便传的,它的意义非常明确:

让悬浮球在计算纵向活动范围时,主动避开底部 HDS 悬浮页签。

也就是说,这个悬浮球不是"能拖就行",而是明确知道:

  • 顶部不能压状态栏安全区
  • 底部不能压住悬浮 TabBar

这也是很多悬浮球"能跑,但不好用"的分水岭。

1. 默认必须收起,不要一上来就展开

悬浮球和悬浮面板是两种东西。

如果默认就是展开态,它当然也能工作,但它在页面里的存在感会非常强,很容易破坏内容阅读。

真正适合作为快捷入口的形态,应该是:

  • 默认收起
  • 体积足够小
  • 视觉上能识别
  • 用户需要时再展开

所以这一版一开始就把组件状态拆成了这样:

ts 复制代码
@Local isExpanded: boolean = false;
@Local isDragging: boolean = false;
@Local dragMoved: boolean = false;

别小看这 3 个状态,它们几乎决定了整个交互模型是不是稳定。

2. 点击和拖动必须分开判断

悬浮球最常见的手感问题,不是拖不动,而是:

  • 明明想点一下展开,结果被识别成拖动
  • 明明在拖,松手时又误触发展开

所以点击和拖动一定不能混在一起。

我们最后引入了一个很简单但很关键的标记:dragMoved

ts 复制代码
private handleDragStart(): void {
  this.isDragging = true;
  this.dragMoved = false;
  this.dragStartX = this.bubbleX;
  this.dragStartY = this.bubbleY;
  if (this.isExpanded) {
    this.isExpanded = false;
  }
}

private handleDragUpdate(event: GestureEvent): void {
  if (Math.abs(event.offsetX) > 1 || Math.abs(event.offsetY) > 1) {
    this.dragMoved = true;
  }
  this.updateBubblePosition(this.dragStartX + event.offsetX, this.dragStartY + event.offsetY, false);
}

private togglePanel(): void {
  if (this.dragMoved) {
    this.dragMoved = false;
    return;
  }
  if (this.isExpanded) {
    this.collapsePanel();
    return;
  }
  this.expandPanel();
}

这段逻辑的核心价值只有一句话:

用户的手到底是在点,还是在拖,组件必须判断得很清楚。

3. 小球不能乱拖,必须有边界意识

如果你只是简单把 x/y 跟着手势走,当然也算实现了"拖动"。

但这种版本很快就会暴露体验问题:

  • 拖到状态栏下面
  • 压住底部悬浮页签
  • 半个球跑出屏幕

所以我们给它做了明确边界:

ts 复制代码
private getTopLimit(): number {
  return WindowUtil.getAvoidArea().top + this.topMargin;
}

private getCollapsedMaxX(): number {
  return Math.max(this.edgeMargin, this.hostWidth - this.bubbleSize - this.edgeMargin);
}

private getCollapsedMaxY(): number {
  const maxY: number = this.hostHeight - this.bubbleSize - WindowUtil.getAvoidArea().bottom -
    this.bottomInset - this.bottomMargin;
  return Math.max(this.getTopLimit(), maxY);
}

private clampBubbleX(x: number): number {
  return Math.min(Math.max(x, this.edgeMargin), this.getCollapsedMaxX());
}

private clampBubbleY(y: number, expanded: boolean): number {
  const maxY: number = expanded ? this.getExpandedMaxY() : this.getCollapsedMaxY();
  return Math.min(Math.max(y, this.getTopLimit()), maxY);
}

也就是说,这里算的不只是屏幕边缘,而是:

  • 顶部系统安全区
  • 底部系统避让区
  • 当前页面底部悬浮页签的占位
  • 收起态和展开态不同高度下的合法范围

一个"顺手"的悬浮球,边界从来不是附属逻辑,而是主体逻辑。

4. 松手必须吸边,不要把球留在屏幕中间当路障

很多悬浮球第一次做出来时,拖动很自由,但一用就觉得烦。

原因通常是它可以停在任意位置,尤其容易停在屏幕中央,变成一个长期悬在内容上的障碍物。

正确做法是:

  • 拖动时自由
  • 松手后稳定

也就是常见的吸边逻辑:

ts 复制代码
private snapToEdge(): void {
  const leftDistance: number = Math.abs(this.bubbleX - this.edgeMargin);
  const rightDistance: number = Math.abs(this.getCollapsedMaxX() - this.bubbleX);
  animateTo({ duration: 180, curve: Curve.Ease }, () => {
    this.bubbleX = leftDistance <= rightDistance ? this.edgeMargin : this.getCollapsedMaxX();
    this.bubbleY = this.clampBubbleY(this.bubbleY, false);
  });
}

这一步看起来很常规,但它决定了组件在"长期停留状态"下是不是干净。

5. 展开方向一定要跟随停靠边

球贴左边,就往右开。

球贴右边,就往左开。

听起来像废话,但这恰恰是最容易漏掉的一件事。

所以我们最终用了一个简单判断:

ts 复制代码
private isExpandRight(): boolean {
  return this.bubbleX + this.bubbleSize / 2 <= this.hostWidth / 2;
}

private getPanelX(): number {
  if (this.isExpandRight()) {
    return this.bubbleX + this.bubbleOverlap;
  }
  return this.bubbleX - (this.panelWidth - this.bubbleSize) - this.bubbleOverlap;
}

这套逻辑的价值,不是"能展开",而是:

无论球贴在哪边,面板都看起来像是朝屏幕内部自然长出来的。

说实话,这个坑几乎决定了这次文章值不值得写。

因为前面的拖动、吸边、展开逻辑,其实都不算太难。

真正耗时间的是:

为什么展开后,小球区域总会把内容挤占掉?

我们最早的思路其实非常直觉:

ts 复制代码
Row({ space: 12 }) {
  this.panelBubbleButton()

  Column({ space: 12 }) {
    // 标题、按钮
  }
}

从代码结构看完全没毛病:

  • 左边一个球
  • 右边一个面板内容区

但问题也恰恰出在这。

因为这意味着:

小球是参与布局计算的。

也就是说,对内容区来说,小球不是"贴边装饰",而是一列真实占位。

后果就是:

  • 内容区可用宽度被压缩
  • 三个动作项容易显得拥挤
  • 右侧停靠场景下,问题尤其明显

这也是为什么你后来一给图例,问题马上就清楚了。

你要的并不是:

球在面板里面,占一列。

你真正要的是:

球半挂在面板外侧,只侵入一部分视觉区域。

这两个理解差异很小,但结构上完全不是一回事。


三、悬浮球展开面板------ Stack 叠层改造

最后我们把展开态改成了 Stack 叠层方案。

也就是:

  • 底层是完整面板内容
  • 上层再把小球贴在左边或右边
  • 内容区只给球的侵入部分留白,而不是给整颗球留一整列

核心代码如下:

ts 复制代码
@Builder
private expandedPanel(): void {
  Stack() {
    Column({ space: 12 }) {
      Row() {
        Column({ space: 2 }) {
          Text('Quick Panel')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFFFF')
          Text('Tap the ball to collapse')
            .fontSize(10)
            .fontColor('#CCF4FBFF')
        }
        .alignItems(HorizontalAlign.Start)

        Blank()
          .layoutWeight(1)

        Text('x')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#E6FFFFFF')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .onClick(() => {
            this.collapsePanel();
          })
      }
      .width(CommonConstants.FULL_PERCENT)
      .justifyContent(FlexAlign.SpaceBetween)

      Row({ space: 14 }) {
        this.actionItem($r('app.media.ic_tab0_selected'), 'Home')
        this.actionItem($r('app.media.widget_location'), 'Locate')
        this.actionItem($r('app.media.ic_security'), 'Guard')
      }
      .width(CommonConstants.FULL_PERCENT)
      .justifyContent(FlexAlign.Start)
    }
    .width(CommonConstants.FULL_PERCENT)
    .height(CommonConstants.FULL_PERCENT)
    .padding({
      left: this.isExpandRight() ? this.contentInset : 16,
      right: this.isExpandRight() ? 16 : this.contentInset,
      top: 12,
      bottom: 12
    })
    .alignItems(HorizontalAlign.Start)

    Stack() {
      this.panelBubbleButton()
    }
    .position({
      x: this.isExpandRight() ? -this.bubbleOverlap : this.panelWidth - this.bubbleSize + this.bubbleOverlap,
      y: (this.panelHeight - this.bubbleSize) / 2
    })
  }
  .width(this.panelWidth)
  .height(this.panelHeight)
  .clip(false)
  .backgroundColor('#ED253A57')
  .borderRadius(28)
  .border({ width: 1, color: '#26FFFFFF' })
  .shadow({
    radius: 20,
    color: '#22000000',
    offsetX: 0,
    offsetY: 12
  })
  .position({ x: this.getPanelX(), y: this.bubbleY })
}

真正起作用的,其实是这 3 个参数:

ts 复制代码
private readonly panelWidth: number = 232;
private readonly bubbleOverlap: number = 20;
private readonly contentInset: number = 40;

它们分别在解决:

  • 面板内容区是否足够宽
  • 小球是否真的向外"挂出去"
  • 内容区到底要给小球留多少侵入空间

这一步做完之后,组件才从"功能上没问题"变成"看起来也像个成熟组件"。

1. @Builder 不是组件实例,别直接链式调用属性

这是我们最先遇到的编译报错:

ts 复制代码
Property 'width' does not exist on type 'void'

原因很简单:

@Builder 方法返回的是 void,不能这么写:

ts 复制代码
this.bubbleCore()
  .width(this.bubbleSize)

正确写法应该包一层真实容器:

ts 复制代码
@Builder
private bubbleButton(): void {
  Stack() {
    this.bubbleCore()
  }
  .width(this.bubbleSize)
  .height(this.bubbleSize)
  .scale(this.isDragging ? { x: 1.06, y: 1.06 } : { x: 1, y: 1 })
  .onClick(() => {
    this.togglePanel();
  })
  .gesture(
    PanGesture()
      .onActionStart(() => {
        this.handleDragStart();
      })
      .onActionUpdate((event: GestureEvent) => {
        this.handleDragUpdate(event);
      })
      .onActionEnd(() => {
        this.handleDragEnd();
      })
  )
}

这是一个特别典型、也特别值得写进团队开发习惯里的坑。

2. 只要元素会伸出容器,就要第一时间想到 .clip(false)

既然小球最终是半挂在面板外侧,那就意味着它一定会有一部分超出面板容器可视范围。

这时候如果你没显式写:

ts 复制代码
.clip(false)

那么某些场景下,小球就会被裁掉一部分。

这个问题特别容易误判,因为:

  • 逻辑没有错
  • 定位也没有错
  • 但视觉上就是少一块

所以做浮层时我现在有个几乎固定的检查项:

只要元素会超出父容器可视范围,就先看这里是不是该关裁剪。

3. 很多布局问题不是"像素不对",而是"结构理解错了"

这次"展开后内容被挤"的问题,就是一个非常典型的例子。

如果没看清结构关系,很容易一路沿着错误方向调:

  • 把面板再拉宽一点
  • 把按钮间距缩小一点
  • 把文字变短一点
  • 把球做小一点

这些都可能暂时缓解,但都不是根因修复。

真正的根因是:

球不该参与内容布局。

一旦结构理解错了,后面的像素优化只会越来越拧。

所以我越来越觉得,做 UI 组件时,很多问题最后不是技术细节,而是建模问题。


四、SubWindow 方案实现全局悬浮球

前面一直在讲页面内方案,但如果只讲到这里,技术视野还是少一层。

因为很多读者读到最后一定会问:

那如果我后面真要做应用内全局悬浮球,怎么升级?

这时候就需要把 SubWindow 放进来。

不过这里我不打算把它写成第二篇完整教程,而是讲清楚它和页面内方案的关系。

一句话先说结论:

页面内悬浮球解决的是"当前页面怎么快速落地",SubWindow 方案解决的是"悬浮球如何从页面组件升级成窗口能力"。

也就是说,它们不是互斥方案,而是前后阶段。


理解ArkUI 子窗口方案的主从关系

如果把 SubWindow 版悬浮球拆成角色,会更容易理解。

大体是这样一层主从关系:

主窗口负责"环境和生命周期":

  • 记录 WindowStage
  • 记录主窗口 ID
  • 监听安全区变化
  • 创建和销毁子窗口
  • 维持主页面和子窗口之间的上下文共享

子窗口负责"悬浮球本体交互":

  • 显示收起态小球
  • 处理展开态面板
  • 拖拽、吸边、方向翻转
  • 快捷动作点击

工具方法负责"窗口级行为":

  • 拖动窗口
  • 调整窗口尺寸
  • 重新贴边
  • 切换焦点

用一句稍微抽象一点的话说:

主窗口负责养这个球,子窗口负责表现这个球,工具层负责让这个球真的像个窗口级浮层。

我们从参考方案里看到的最有价值的一点,不是某个具体方法名,而是职责边界非常清晰。

主窗口里,大概是这种思路:

ts 复制代码
windowStage.loadContent('pages/Index', () => {
  AppStorage.setOrCreate('windowStage', windowStage);
  AppStorage.setOrCreate('mainWindowId', windowStage.getMainWindowSync().getWindowProperties().id);

  const mainWindow = windowStage.getMainWindowSync();
  mainWindow.setWindowLayoutFullScreen(true);

  let avoidArea = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
  AppStorage.setOrCreate('bottomRectHeight', avoidArea.bottomRect.height);

  avoidArea = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
  AppStorage.setOrCreate('topRectHeight', avoidArea.topRect.height);
});

这段代码的价值不只是"初始化成功了",而是说明:

  • 悬浮球后续拖动边界,不是自己乱算
  • 它依赖主窗口持续提供安全区信息
  • 子窗口虽然独立显示,但它的环境仍然来自主窗口

然后在页面入口里,再去创建子窗口:

ts 复制代码
this.windowStage.createSubWindow('ToolBallSubWindow', (err, windowClass) => {
  if (err.code) {
    return;
  }

  windowClass.setUIContent('subwindow/ToolBallSubWindow', () => {
    windowClass.setWindowBackgroundColor('#00000000');
  });

  windowClass.moveWindowTo(1100, 2300);
  windowClass.resize(
    this.getUIContext().vp2px(56),
    this.getUIContext().vp2px(56)
  );
  windowClass.showWindow();
});

你会发现这时候主窗口做的事情很像一个"宿主":

  • 负责把球生出来
  • 负责给它一块窗口级容器
  • 但不负责具体交互细节

而交互则放在子窗口页面里:

ts 复制代码
PanGesture()
  .onActionStart(() => {
    // 如果当前是展开态,先收起
  })
  .onActionUpdate((event: GestureEvent) => {
    // 更新窗口位置
  })
  .onActionEnd(() => {
    // 贴边并记录方向
  })

这就是一个非常典型的主从结构:

  • 主负责生命周期和环境
  • 从负责视图和动作

如果以后你真的要做全局悬浮球,这个思路比单纯记住几个 API 名字更重要。


页面内方案和 SubWindow 方案,如何做工程决策

如果把它们做一个非常实用的判断表,大概是这样:

如果你现在更在意这些,选页面内方案
  • 快速落地
  • 先验证交互
  • 只服务于当前页面
  • 不想动窗口层管理
  • 希望和页面一起调试 UI
如果你现在更在意这些,选 SubWindow 方案
  • 跨页面持续存在
  • 更独立的生命周期
  • 更接近应用内全局悬浮
  • 未来会继续扩展更多动作和状态
  • 愿意补齐窗口管理与安全区同步

所以说到底,不是哪个更"高级",而是谁更适合你当下阶段。

我个人现在更推荐的顺序是:

  1. 先做页面内悬浮球,把手感和业务定位做对
  2. 确认它真的需要跨页面持续存在
  3. 再升级到 SubWindow

这样整个工程投入会更稳,也更不容易把时间浪费在过早设计上。


结语

这次回看整个过程,我觉得最稳的落地顺序其实很明确:

先别急着做展开、拖动、吸边。

让它先稳定显示在正确位置,就已经完成了第一阶段。

重点处理:

  • 顶部安全区
  • 底部系统避让区
  • 当前页面底部悬浮页签

这一步做完之后,悬浮球的基础交互就已经是"可用的"了。

而且展开态我强烈建议直接按"叠层 + 半悬挂"去做,不要先做"球占一列"的版本。

因为后者你大概率还是会推翻重来。

如果让我用一句话总结这次改造,我会说:

悬浮球最重要的不是功能多,而是别让用户和它较劲。

默认收起、拖动顺滑、松手吸边、点击展开、朝内打开、内容不被挤占、和现有页面和平共处,这几件事一旦同时成立,它就已经是一个非常合格的业务组件了。

后面要不要升级成 SubWindow 版、要不要做位置持久化、要不要接更多动作,那都是第二阶段的问题。

先把第一阶段做对,通常比什么都重要。

相关推荐
Lanren的编程日记3 小时前
Flutter 鸿蒙应用AR功能集成实战:多平台AR框架+模拟模式,打造增强现实体验
flutter·ar·harmonyos
南村群童欺我老无力.3 小时前
鸿蒙PC开发的Slider组件blockSize参数的类型要求
华为·harmonyos
前端技术4 小时前
华为余承东:鸿蒙终端设备数突破5500万
java·前端·javascript·人工智能·python·华为·harmonyos
代码论斤卖4 小时前
OpenHarmony的watchdog service频繁崩溃问题分析
linux·harmonyos
Lanren的编程日记5 小时前
Flutter 鸿蒙应用权限管理功能实战:标准化权限申请与状态管控,提升用户信任度
flutter·华为·harmonyos
想你依然心痛5 小时前
HarmonyOS 6(API 23)实战:基于 HDS 沉浸光感与悬浮导航打造“光影工作台“多窗口协作系统
microsoft·华为·harmonyos·悬浮导航·沉浸光感
Ww.xh5 小时前
OpenHarmony API 9 升级到 API 10 权限与接口变更实战指南
服务器·华为·harmonyos
枫叶丹45 小时前
【HarmonyOS 6.0】ArkWeb新特性:PDF加载成功/失败回调及滚动到底部监听
华为·pdf·harmonyos
南村群童欺我老无力.5 小时前
鸿蒙 - Progress进度条从手工拼装到原生组件的重构
华为·重构·harmonyos