HarmonyOS 悬浮球实战从页面内组件到 SubWindow 方案一、需求整理——从页面内组件探索悬浮控件二、抽离通用组件 FloatingToolbox1. 默认必须收起不要一上来就展开2. 点击和拖动必须分开判断3. 小球不能乱拖必须有边界意识4. 松手必须吸边不要把球留在屏幕中间当路障5. 展开方向一定要跟随停靠边三、悬浮球展开面板—— Stack 叠层改造1. Builder 不是组件实例别直接链式调用属性2. 只要元素会伸出容器就要第一时间想到 .clip(false)3. 很多布局问题不是“像素不对”而是“结构理解错了”四、SubWindow 方案实现全局悬浮球理解ArkUI 子窗口方案的主从关系页面内方案和 SubWindow 方案如何做工程决策如果你现在更在意这些选页面内方案如果你现在更在意这些选 SubWindow 方案结语摘要很多 HarmonyOS 项目在完成 HDS 悬浮页签、沉浸光感之后页面已经足够“轻”和“系统”但高频快捷入口依然缺一个顺手的落点。本文结合一次真实改造过程讲清楚我们为什么先做页面内悬浮球、它到底解决了什么问题、实现里最关键的交互细节是什么、踩了哪些典型 ArkUI 坑以及如果后续想升级成应用内全局悬浮球SubWindow方案应该怎么理解主从关系。如果你最近在做 HarmonyOS 首页改造尤其已经在接入这些能力HDS 悬浮页签沉浸光感材质miniBar 轻信息区更沉浸的内容布局你很快会碰到一个很现实的问题主导航已经够满了但业务还想再塞几个高频动作。这类动作通常都很典型返回顶部快速定位打开某个常用面板一键触发某个安全或工具能力在内容流中保留一个稳定可见的快捷入口问题在于这些能力都很常用但又不太适合直接放进主导航。放到底部 Tab太重。放到右上角菜单太深。做成普通按钮太硬。所以这次改造里我们最终选的是一个更符合这类需求气质的方案悬浮球。但本文要讲的不是跨应用的系统级悬浮窗而是一个更适合业务项目先落地的版本页面内悬浮球。一、需求整理——从页面内组件探索悬浮控件这次改造开始前我们手里其实已经有一份参考方案核心思路是用 ArkUI 的SubWindow做应用内悬浮球。那套方案的能力非常完整适合这些场景悬浮球需要跨多个页面持续存在希望它和当前页面布局进一步解耦需要更接近“应用内全局悬浮”的效果后续还会持续扩展更多动作和状态从能力角度看SubWindow当然更强。但我们当时的实际需求没有那么重。更准确地说我们眼前要解决的是一个首页快捷入口问题而不是一个窗口管理问题。那时更重要的是先在现有首页里快速落地先验证交互是不是顺手不改大结构不补整套窗口生命周期尽量复用当前工程已有资源和页面结构于是我们最终做了一个很重要的工程判断第一版先做页面内悬浮球。这个判断的价值在于它让我们把问题压缩成了一个更适合快速验证的范围一个独立组件一处顶层挂载一套相对轻量但完整的交互模型这不是“做简单版”而是先做“最值得做的版本”。如果把需求压缩成一句话它不是“做一个悬浮控件”而是做一个用户不觉得烦、页面不觉得乱、工程也不觉得重的悬浮球。拆开之后目标其实非常明确默认收起为小球不是默认展开小球支持拖动松手之后自动吸附左右边缘点击小球展开快捷面板面板展开方向要跟随停靠边始终朝屏幕内侧打开要避开顶部安全区和底部 HDS 悬浮页签尽量做成一个独立组件接到现有页面里就能用这一版里我们不追求一开始就把能力做到“最全”而是先把下面几件事做对收起态是否轻量拖动是否顺滑点击和拖动是否冲突展开后内容有没有被挤占组件有没有破坏现有页面交互这些问题如果没做顺哪怕你用了更重的方案最后用户感受到的也还是“不好用”。二、抽离通用组件FloatingToolbox这次方案最终落得很克制。页面层只做一件事挂载。组件层负责一切交互和 UI。也就是说Main.ets不负责悬浮球的状态管理它只负责告诉组件你现在处在一个什么页面里你底部需要避开什么区域挂载代码非常直接FloatingToolbox({bottomInset:HDS_BAR_HEIGHTHDS_BAR_BOTTOM_MARGIN*2})这里的bottomInset不是随便传的它的意义非常明确让悬浮球在计算纵向活动范围时主动避开底部 HDS 悬浮页签。也就是说这个悬浮球不是“能拖就行”而是明确知道顶部不能压状态栏安全区底部不能压住悬浮 TabBar这也是很多悬浮球“能跑但不好用”的分水岭。1. 默认必须收起不要一上来就展开悬浮球和悬浮面板是两种东西。如果默认就是展开态它当然也能工作但它在页面里的存在感会非常强很容易破坏内容阅读。真正适合作为快捷入口的形态应该是默认收起体积足够小视觉上能识别用户需要时再展开所以这一版一开始就把组件状态拆成了这样LocalisExpanded:booleanfalse;LocalisDragging:booleanfalse;LocaldragMoved:booleanfalse;别小看这 3 个状态它们几乎决定了整个交互模型是不是稳定。2. 点击和拖动必须分开判断悬浮球最常见的手感问题不是拖不动而是明明想点一下展开结果被识别成拖动明明在拖松手时又误触发展开所以点击和拖动一定不能混在一起。我们最后引入了一个很简单但很关键的标记dragMoved。privatehandleDragStart():void{this.isDraggingtrue;this.dragMovedfalse;this.dragStartXthis.bubbleX;this.dragStartYthis.bubbleY;if(this.isExpanded){this.isExpandedfalse;}}privatehandleDragUpdate(event:GestureEvent):void{if(Math.abs(event.offsetX)1||Math.abs(event.offsetY)1){this.dragMovedtrue;}this.updateBubblePosition(this.dragStartXevent.offsetX,this.dragStartYevent.offsetY,false);}privatetogglePanel():void{if(this.dragMoved){this.dragMovedfalse;return;}if(this.isExpanded){this.collapsePanel();return;}this.expandPanel();}这段逻辑的核心价值只有一句话用户的手到底是在点还是在拖组件必须判断得很清楚。3. 小球不能乱拖必须有边界意识如果你只是简单把x/y跟着手势走当然也算实现了“拖动”。但这种版本很快就会暴露体验问题拖到状态栏下面压住底部悬浮页签半个球跑出屏幕所以我们给它做了明确边界privategetTopLimit():number{returnWindowUtil.getAvoidArea().topthis.topMargin;}privategetCollapsedMaxX():number{returnMath.max(this.edgeMargin,this.hostWidth-this.bubbleSize-this.edgeMargin);}privategetCollapsedMaxY():number{constmaxY:numberthis.hostHeight-this.bubbleSize-WindowUtil.getAvoidArea().bottom-this.bottomInset-this.bottomMargin;returnMath.max(this.getTopLimit(),maxY);}privateclampBubbleX(x:number):number{returnMath.min(Math.max(x,this.edgeMargin),this.getCollapsedMaxX());}privateclampBubbleY(y:number,expanded:boolean):number{constmaxY:numberexpanded?this.getExpandedMaxY():this.getCollapsedMaxY();returnMath.min(Math.max(y,this.getTopLimit()),maxY);}也就是说这里算的不只是屏幕边缘而是顶部系统安全区底部系统避让区当前页面底部悬浮页签的占位收起态和展开态不同高度下的合法范围一个“顺手”的悬浮球边界从来不是附属逻辑而是主体逻辑。4. 松手必须吸边不要把球留在屏幕中间当路障很多悬浮球第一次做出来时拖动很自由但一用就觉得烦。原因通常是它可以停在任意位置尤其容易停在屏幕中央变成一个长期悬在内容上的障碍物。正确做法是拖动时自由松手后稳定也就是常见的吸边逻辑privatesnapToEdge():void{constleftDistance:numberMath.abs(this.bubbleX-this.edgeMargin);constrightDistance:numberMath.abs(this.getCollapsedMaxX()-this.bubbleX);animateTo({duration:180,curve:Curve.Ease},(){this.bubbleXleftDistancerightDistance?this.edgeMargin:this.getCollapsedMaxX();this.bubbleYthis.clampBubbleY(this.bubbleY,false);});}这一步看起来很常规但它决定了组件在“长期停留状态”下是不是干净。5. 展开方向一定要跟随停靠边球贴左边就往右开。球贴右边就往左开。听起来像废话但这恰恰是最容易漏掉的一件事。所以我们最终用了一个简单判断privateisExpandRight():boolean{returnthis.bubbleXthis.bubbleSize/2this.hostWidth/2;}privategetPanelX():number{if(this.isExpandRight()){returnthis.bubbleXthis.bubbleOverlap;}returnthis.bubbleX-(this.panelWidth-this.bubbleSize)-this.bubbleOverlap;}这套逻辑的价值不是“能展开”而是无论球贴在哪边面板都看起来像是朝屏幕内部自然长出来的。说实话这个坑几乎决定了这次文章值不值得写。因为前面的拖动、吸边、展开逻辑其实都不算太难。真正耗时间的是为什么展开后小球区域总会把内容挤占掉我们最早的思路其实非常直觉Row({space:12}){this.panelBubbleButton()Column({space:12}){// 标题、按钮}}从代码结构看完全没毛病左边一个球右边一个面板内容区但问题也恰恰出在这。因为这意味着小球是参与布局计算的。也就是说对内容区来说小球不是“贴边装饰”而是一列真实占位。后果就是内容区可用宽度被压缩三个动作项容易显得拥挤右侧停靠场景下问题尤其明显这也是为什么你后来一给图例问题马上就清楚了。你要的并不是球在面板里面占一列。你真正要的是球半挂在面板外侧只侵入一部分视觉区域。这两个理解差异很小但结构上完全不是一回事。三、悬浮球展开面板——Stack叠层改造最后我们把展开态改成了Stack叠层方案。也就是底层是完整面板内容上层再把小球贴在左边或右边内容区只给球的侵入部分留白而不是给整颗球留一整列核心代码如下BuilderprivateexpandedPanel():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.bubbleSizethis.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 个参数privatereadonlypanelWidth:number232;privatereadonlybubbleOverlap:number20;privatereadonlycontentInset:number40;它们分别在解决面板内容区是否足够宽小球是否真的向外“挂出去”内容区到底要给小球留多少侵入空间这一步做完之后组件才从“功能上没问题”变成“看起来也像个成熟组件”。1.Builder不是组件实例别直接链式调用属性这是我们最先遇到的编译报错Propertywidthdoes not exist ontypevoid原因很简单Builder方法返回的是void不能这么写this.bubbleCore().width(this.bubbleSize)正确写法应该包一层真实容器BuilderprivatebubbleButton():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)既然小球最终是半挂在面板外侧那就意味着它一定会有一部分超出面板容器可视范围。这时候如果你没显式写.clip(false)那么某些场景下小球就会被裁掉一部分。这个问题特别容易误判因为逻辑没有错定位也没有错但视觉上就是少一块所以做浮层时我现在有个几乎固定的检查项只要元素会超出父容器可视范围就先看这里是不是该关裁剪。3. 很多布局问题不是“像素不对”而是“结构理解错了”这次“展开后内容被挤”的问题就是一个非常典型的例子。如果没看清结构关系很容易一路沿着错误方向调把面板再拉宽一点把按钮间距缩小一点把文字变短一点把球做小一点这些都可能暂时缓解但都不是根因修复。真正的根因是球不该参与内容布局。一旦结构理解错了后面的像素优化只会越来越拧。所以我越来越觉得做 UI 组件时很多问题最后不是技术细节而是建模问题。四、SubWindow方案实现全局悬浮球前面一直在讲页面内方案但如果只讲到这里技术视野还是少一层。因为很多读者读到最后一定会问那如果我后面真要做应用内全局悬浮球怎么升级这时候就需要把SubWindow放进来。不过这里我不打算把它写成第二篇完整教程而是讲清楚它和页面内方案的关系。一句话先说结论页面内悬浮球解决的是“当前页面怎么快速落地”SubWindow方案解决的是“悬浮球如何从页面组件升级成窗口能力”。也就是说它们不是互斥方案而是前后阶段。理解ArkUI 子窗口方案的主从关系如果把SubWindow版悬浮球拆成角色会更容易理解。大体是这样一层主从关系主窗口负责“环境和生命周期”记录WindowStage记录主窗口 ID监听安全区变化创建和销毁子窗口维持主页面和子窗口之间的上下文共享子窗口负责“悬浮球本体交互”显示收起态小球处理展开态面板拖拽、吸边、方向翻转快捷动作点击工具方法负责“窗口级行为”拖动窗口调整窗口尺寸重新贴边切换焦点用一句稍微抽象一点的话说主窗口负责养这个球子窗口负责表现这个球工具层负责让这个球真的像个窗口级浮层。我们从参考方案里看到的最有价值的一点不是某个具体方法名而是职责边界非常清晰。主窗口里大概是这种思路windowStage.loadContent(pages/Index,(){AppStorage.setOrCreate(windowStage,windowStage);AppStorage.setOrCreate(mainWindowId,windowStage.getMainWindowSync().getWindowProperties().id);constmainWindowwindowStage.getMainWindowSync();mainWindow.setWindowLayoutFullScreen(true);letavoidAreamainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);AppStorage.setOrCreate(bottomRectHeight,avoidArea.bottomRect.height);avoidAreamainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);AppStorage.setOrCreate(topRectHeight,avoidArea.topRect.height);});这段代码的价值不只是“初始化成功了”而是说明悬浮球后续拖动边界不是自己乱算它依赖主窗口持续提供安全区信息子窗口虽然独立显示但它的环境仍然来自主窗口然后在页面入口里再去创建子窗口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();});你会发现这时候主窗口做的事情很像一个“宿主”负责把球生出来负责给它一块窗口级容器但不负责具体交互细节而交互则放在子窗口页面里PanGesture().onActionStart((){// 如果当前是展开态先收起}).onActionUpdate((event:GestureEvent){// 更新窗口位置}).onActionEnd((){// 贴边并记录方向})这就是一个非常典型的主从结构主负责生命周期和环境从负责视图和动作如果以后你真的要做全局悬浮球这个思路比单纯记住几个 API 名字更重要。页面内方案和SubWindow方案如何做工程决策如果把它们做一个非常实用的判断表大概是这样如果你现在更在意这些选页面内方案快速落地先验证交互只服务于当前页面不想动窗口层管理希望和页面一起调试 UI如果你现在更在意这些选SubWindow方案跨页面持续存在更独立的生命周期更接近应用内全局悬浮未来会继续扩展更多动作和状态愿意补齐窗口管理与安全区同步所以说到底不是哪个更“高级”而是谁更适合你当下阶段。我个人现在更推荐的顺序是先做页面内悬浮球把手感和业务定位做对确认它真的需要跨页面持续存在再升级到SubWindow这样整个工程投入会更稳也更不容易把时间浪费在过早设计上。结语这次回看整个过程我觉得最稳的落地顺序其实很明确、先别急着做展开、拖动、吸边。让它先稳定显示在正确位置就已经完成了第一阶段。重点处理顶部安全区底部系统避让区当前页面底部悬浮页签这一步做完之后悬浮球的基础交互就已经是“可用的”了。而且展开态我强烈建议直接按“叠层 半悬挂”去做不要先做“球占一列”的版本。因为后者你大概率还是会推翻重来。如果让我用一句话总结这次改造我会说悬浮球最重要的不是功能多而是别让用户和它较劲。默认收起、拖动顺滑、松手吸边、点击展开、朝内打开、内容不被挤占、和现有页面和平共处这几件事一旦同时成立它就已经是一个非常合格的业务组件了。后面要不要升级成SubWindow版、要不要做位置持久化、要不要接更多动作那都是第二阶段的问题。先把第一阶段做对通常比什么都重要。