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. 很多布局问题不是“像素不对”,而是“结构理解错了”)
- [1. `@Builder` 不是组件实例,别直接链式调用属性](#1.
- [四、`SubWindow` 方案实现全局悬浮球](#四、
SubWindow方案实现全局悬浮球) -
- [理解ArkUI 子窗口方案的主从关系](#理解ArkUI 子窗口方案的主从关系)
- [页面内方案和 `SubWindow` 方案,如何做工程决策](#页面内方案和
SubWindow方案,如何做工程决策) -
- 如果你现在更在意这些,选页面内方案
- [如果你现在更在意这些,选 `SubWindow` 方案](#如果你现在更在意这些,选
SubWindow方案)
- 结语
摘要:很多 HarmonyOS 项目在完成 HDS 悬浮页签、沉浸光感之后,页面已经足够"轻"和"系统",但高频快捷入口依然缺一个顺手的落点。本文结合一次真实改造过程,讲清楚我们为什么先做页面内悬浮球、它到底解决了什么问题、实现里最关键的交互细节是什么、踩了哪些典型 ArkUI 坑,以及如果后续想升级成应用内全局悬浮球,
SubWindow方案应该怎么理解主从关系。
如果你最近在做 HarmonyOS 首页改造,尤其已经在接入这些能力:
- HDS 悬浮页签
- 沉浸光感材质
- miniBar 轻信息区
- 更沉浸的内容布局
你很快会碰到一个很现实的问题:
主导航已经够满了,但业务还想再塞几个高频动作。
这类动作通常都很典型:
- 返回顶部
- 快速定位
- 打开某个常用面板
- 一键触发某个安全或工具能力
- 在内容流中保留一个稳定可见的快捷入口
问题在于,这些能力都很常用,但又不太适合直接放进主导航。
放到底部 Tab,太重。
放到右上角菜单,太深。
做成普通按钮,太硬。
所以这次改造里,我们最终选的是一个更符合这类需求气质的方案:

悬浮球。
但本文要讲的,不是跨应用的系统级悬浮窗,而是一个更适合业务项目先落地的版本:
页面内悬浮球。
一、需求整理------从页面内组件探索悬浮控件
这次改造开始前,我们手里其实已经有一份参考方案,核心思路是用 ArkUI 的 SubWindow 做应用内悬浮球。
那套方案的能力非常完整,适合这些场景:
- 悬浮球需要跨多个页面持续存在
- 希望它和当前页面布局进一步解耦
- 需要更接近"应用内全局悬浮"的效果
- 后续还会持续扩展更多动作和状态
从能力角度看,SubWindow 当然更强。
但我们当时的实际需求没有那么重。
更准确地说,我们眼前要解决的,是一个首页快捷入口问题,而不是一个窗口管理问题。那时更重要的是:
- 先在现有首页里快速落地
- 先验证交互是不是顺手
- 不改大结构,不补整套窗口生命周期
- 尽量复用当前工程已有资源和页面结构
于是我们最终做了一个很重要的工程判断:
第一版先做页面内悬浮球。
这个判断的价值在于,它让我们把问题压缩成了一个更适合快速验证的范围:
- 一个独立组件
- 一处顶层挂载
- 一套相对轻量但完整的交互模型
这不是"做简单版",而是先做"最值得做的版本"。
如果把需求压缩成一句话,它不是"做一个悬浮控件",而是:
做一个用户不觉得烦、页面不觉得乱、工程也不觉得重的悬浮球。
拆开之后,目标其实非常明确:
- 默认收起为小球,不是默认展开
- 小球支持拖动
- 松手之后自动吸附左右边缘
- 点击小球展开快捷面板
- 面板展开方向要跟随停靠边,始终朝屏幕内侧打开
- 要避开顶部安全区和底部 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 方案
- 跨页面持续存在
- 更独立的生命周期
- 更接近应用内全局悬浮
- 未来会继续扩展更多动作和状态
- 愿意补齐窗口管理与安全区同步
所以说到底,不是哪个更"高级",而是谁更适合你当下阶段。
我个人现在更推荐的顺序是:
- 先做页面内悬浮球,把手感和业务定位做对
- 确认它真的需要跨页面持续存在
- 再升级到
SubWindow
这样整个工程投入会更稳,也更不容易把时间浪费在过早设计上。
结语
这次回看整个过程,我觉得最稳的落地顺序其实很明确:
、
先别急着做展开、拖动、吸边。
让它先稳定显示在正确位置,就已经完成了第一阶段。
重点处理:
- 顶部安全区
- 底部系统避让区
- 当前页面底部悬浮页签
这一步做完之后,悬浮球的基础交互就已经是"可用的"了。
而且展开态我强烈建议直接按"叠层 + 半悬挂"去做,不要先做"球占一列"的版本。
因为后者你大概率还是会推翻重来。
如果让我用一句话总结这次改造,我会说:
悬浮球最重要的不是功能多,而是别让用户和它较劲。
默认收起、拖动顺滑、松手吸边、点击展开、朝内打开、内容不被挤占、和现有页面和平共处,这几件事一旦同时成立,它就已经是一个非常合格的业务组件了。
后面要不要升级成 SubWindow 版、要不要做位置持久化、要不要接更多动作,那都是第二阶段的问题。
先把第一阶段做对,通常比什么都重要。