HarmonyOS 7 闪控窗实战 02:窗口拖动 + 位置约束完善“轻任务悬浮台”自由悬浮体验【鸿蒙心迹】
第一篇里,我给“轻任务悬浮台”做出了一个最小可跑通版本:主页面能创建任务,闪控窗能出现,任务状态可以在主页面和悬浮卡片之间同步变化。那一版的重点是把链路打通,所以我刻意没有把问题铺得太开。
但真正把它当成一个稍微像样的 Demo 再多玩一会儿,很快就会遇到第二层问题:这个窗虽然已经能显示任务状态了,但它还不像一个真正好用的悬浮窗。
原因很简单。第一版的闪控窗更多像一个“被打开的小卡片”,而不是一个“可以自由操作的悬浮任务入口”。用户会马上产生下面这些期待:
- 我能不能把它拖到自己顺手的位置?
- 如果我拖到边缘,系统是不是应该帮我处理停靠?
- 下次再打开时,它能不能记住我上一次放的位置?
- 如果拖得太靠外,窗口会不会直接跑出屏幕?
这些问题看起来像交互细节,但其实已经开始决定这个 Demo 是“仅仅能跑”,还是“开始有真实体验”。
所以第二篇,我只做一件事:把闪控窗从“能显示”推进到“能自由悬浮,而且悬浮得更稳”。
换句话说,这一篇的核心主题不是新增很多业务功能,而是围绕三个关键词把基础体验补完整:
- 拖动:用户可以长按并移动悬浮窗;
- 约束:悬浮窗不能乱飞,必须被约束在合理区域内;
- 恢复:上一次停留的位置可以被记录和恢复。
这三个词合在一起,才是我理解里的“第二篇真正该补的东西”。

一、为什么第二篇一定要先处理“自由悬浮”
如果把这套五连载当成一个渐进式工程,而不是五篇彼此独立的文章,那么第二篇的位置其实非常明确:它负责让第一篇做出来的“任务窗”真正长出交互能力。
第一篇解决的是“有没有”;第二篇解决的是“好不好用”。
对闪控窗来说,好不好用最先体现在哪里?我觉得不是样式多精致,也不是动画多炫,而是它是不是顺手。
所谓顺手,至少包括以下几层感受:
- 用户拖动时,窗口要跟手;
- 停下时,位置要稳定;
- 不会因为手滑就飞到屏幕外;
- 接近边缘时,应该有一种“被系统接住”的感觉;
- 重新打开时,不应该每次都回到初始角落。
这些感受,放在产品语言里叫交互体验;放在工程语言里,其实就是坐标计算、边界限制、停靠策略和位置持久化。
也正因为如此,我觉得第二篇最适合做“工程味”一点的内容。它不再只是讲一个功能点,而是开始把 UI、状态和坐标逻辑合在一起考虑。
二、第二篇我给自己定的交付目标
为了避免一口气把拖拽、吸附、手势、临时收起、侧边栏暂存全部混在一起,这一篇我还是给自己做了边界控制。
第二篇的交付目标,我只锁定为下面四件事:
- 闪控窗可以被按住拖动;
- 拖动过程中,位置实时刷新;
- 松手后如果靠近左右边缘,自动吸附到合适位置;
- 当前停留位置保存到本地,下次打开时恢复。
注意,这里我故意只做“边缘约束 + 边缘吸附 + 位置恢复”,而没有继续往“侧边栏暂存”上走。因为第三篇更适合专门处理那个主题。
如果第二篇就把所有和位置相关的行为一锅端,文章会很散。相反,只围绕“拖动”这条线深挖,逻辑会特别顺:
- 为什么需要拖动;
- 拖动状态如何采集;
- 坐标如何更新;
- 怎么判断边缘;
- 怎么决定吸附;
- 怎么把结果记下来。
我很喜欢这种写法,因为它让文章像一条清晰的调试路径,而不是功能清单。
三、目录结构我没有推倒重来,只是把责任边界继续拉清楚
第二篇的好处是,第一篇已经搭好了主骨架,所以我不需要再回头重做目录。我只是让原有结构更明确一点。
LightTaskDemo
├─ entry/src/main/ets
│ ├─ pages
│ │ ├─ Index.ets
│ │ └─ FloatWindowPage.ets
│ ├─ components
│ │ └─ FloatTaskCard.ets
│ ├─ model
│ │ └─ TaskInfo.ets
│ ├─ service
│ │ ├─ TaskManager.ets
│ │ └─ FloatManager.ets
│ └─ common
│ ├─ Logger.ets
│ └─ WindowStore.ets
└─ resources
这里最关键的变化有两个。
1)FloatWindowManager 更像一个窗口总入口
第一篇里它只负责“开、关、更新”,第二篇以后,它开始真正接住与窗口位置有关的逻辑。
2)位置持久化不直接塞到页面里
我新增了一个很轻量的 WindowStore 概念,哪怕只是简单封装 Preferences,也比把保存与恢复逻辑散落在页面里更清楚。
因为第二篇之后,你会越来越发现:闪控窗不是页面组件本身,而是一个带状态、带位置、带生命周期的独立交互体。
只要意识到这一点,代码结构就不应该完全 UI 化。

四、拖动这件事,先别急着写动画,先把状态采集做干净
很多人一说做拖动,第一反应是去想“窗口移动时要不要做一点丝滑动画”。但我这次恰恰反过来:先不追动画,先把输入状态采集做可靠。
因为只有当下面这几组数据稳定时,拖动才有可能是可控的:
- 用户按下时的起点坐标;
- 当前手指移动到的位置;
- 窗口原始位置;
- 当前计算出的目标位置;
- 此时是否处于拖动态。
所以我先给 FloatManager 定了一组拖拽相关状态。
export class FloatManager {
private startX: number = 0
private startY: number = 0
private currentX: number = 0
private currentY: number = 0
private originX: number = 0
private originY: number = 0
private isDragging: boolean = false
private windowClass: window.Window | null = null
init(windowClass: window.Window, originX: number, originY: number) {
this.windowClass = windowClass
this.originX = originX
this.originY = originY
}
}
这段代码没有任何花哨之处,但它决定了第二篇的基础是否稳固。
尤其是 originX 和 originY 这两个值,我觉得特别值得保留。因为很多拖动逻辑之所以容易抖,不是因为更新不及时,而是因为每次移动时没有以“原始位置 + 位移量”作为统一参考,导致窗口跟手时会有跳变感。
五、拖动开始、移动、结束,我把它拆成三个明确阶段
第二篇最核心的交互链路,就是把悬浮窗拖动拆成三个阶段:
- 开始拖动:记录起点;
- 拖动中:计算位移并刷新位置;
- 结束拖动:做边缘判定、吸附和位置保存。
我很喜欢这种阶段化拆分,因为它让复杂感一下就被压下去了。
1)开始拖动
onTouchStart(event: TouchEvent) {
this.isDragging = true
this.startX = event.touches[0].screenX
this.startY = event.touches[0].screenY
this.currentX = this.startX
this.currentY = this.startY
Logger.info('FloatManager', `drag start x=${this.startX}, y=${this.startY}`)
}
这里最关键的是:开始拖动时不直接更新窗口,只做记录。
2)拖动过程中实时刷新
onTouchMove(event: TouchEvent) {
if (!this.isDragging || !this.windowClass) {
return
}
this.currentX = event.touches[0].screenX
this.currentY = event.touches[0].screenY
const targetX = this.originX + (this.currentX - this.startX)
const targetY = this.originY + (this.currentY - this.startY)
const pos = this.clampPosition(targetX, targetY)
this.updatePosition(pos.x, pos.y)
}
这里我没有一步到位去做复杂磁吸,而是先做一层 clampPosition。我觉得这一步特别重要,因为它意味着:无论用户怎么拖,窗口的实时位置都要先保证合法。
3)拖动结束,做最终决策
async onTouchEnd() {
if (!this.windowClass) {
return
}
this.isDragging = false
const snapped = this.snapToEdge(this.originX, this.originY)
await this.updatePosition(snapped.x, snapped.y)
await this.saveWindowPosition(snapped.x, snapped.y)
Logger.info('FloatManager', `drag end x=${snapped.x}, y=${snapped.y}`)
}
这里才是第二篇真正“体验升级”的地方。拖动结束以后,窗口不是停在一个随机坐标,而是会按照策略被吸附到更自然的位置。
六、约束不是限制用户,而是在帮体验兜底
我在做这一篇时,非常想强调一个点:边界约束不是为了限制用户自由,而是为了防止自由变成失控。
如果完全不做约束,会出现很多很难受的情况:
- 窗口拖出右边后,用户可能只剩下一角能看见;
- 顶部状态区可能被压住;
- 底部任务栏区域可能被遮挡;
- 再次拖回来的成本反而更高。
所以第二篇里,我对位置计算做了最基础的约束。
private clampPosition(x: number, y: number): { x: number; y: number } {
const screenWidth = display.getDefaultDisplaySync().width
const screenHeight = display.getDefaultDisplaySync().height
const width = 320
const height = 200
const clampedX = Math.max(0, Math.min(x, screenWidth - width))
const clampedY = Math.max(0, Math.min(y, screenHeight - height))
return { x: clampedX, y: clampedY }
}
这段代码看起来甚至有点“朴素得过头”,但它解决的问题非常实在:不管拖动过程中的输入坐标怎样变化,窗口最终都只会落在屏幕允许的矩形区域内。
我越来越喜欢这种写法。它并不依赖某个复杂组件,也不需要把手势系统讲得特别重,只是把最关键的工程边界抓住了。
七、边缘吸附的核心不是炫技,而是让停靠位置可预期
光有 clampPosition 还不够。因为如果拖动结束后窗口只是“停在合法位置”,它依旧可能显得有些悬空,尤其是离边缘只差一点点时,用户会天然期待系统帮它完成最后一步。
这就是边缘吸附存在的意义。
第二篇里,我只先做左右边缘吸附,不急着处理更多复杂停靠策略。因为对一个第一版交互体验来说,这已经足够让“悬浮”变得顺手很多。
private snapToEdge(x: number, y: number): { x: number; y: number } {
const screenWidth = display.getDefaultDisplaySync().width
const width = 320
const edgeThreshold = 80
if (x <= edgeThreshold) {
return { x: 0, y }
}
if (x >= screenWidth - width - edgeThreshold) {
return { x: screenWidth - width, y }
}
return { x, y }
}
这里我故意把逻辑写得很直白:
- 靠左边足够近,就吸到最左;
- 靠右边足够近,就吸到最右;
- 其他情况保持当前位置。
为什么不一步到位做更细腻的“磁力区”?
因为第二篇最重要的不是把策略写得多聪明,而是先让用户感受到一个稳定的反馈:我拖过去了,系统确实理解我的意图了。

八、位置恢复是这篇最容易被低估、但最能体现完成度的一环
如果只做到拖动和吸附,其实已经能让人感到比第一篇顺手很多。但我自己做完后,还是觉得少了点什么。
后来想明白了:少的是“延续感”。
用户既然已经花了一次动作把窗口放到了自己顺手的位置,那应用最好别每次都假装没发生过这件事。否则体验就会断掉。
所以第二篇我专门补了位置保存与恢复。
private async saveWindowPosition(x: number, y: number): Promise<void> {
const pos = { x, y }
await Preferences.put('float_window_last_position', JSON.stringify(pos))
Logger.info('FloatManager', `save position x=${x}, y=${y}`)
}
private async restoreWindowPosition(): Promise<{ x: number; y: number }> {
const value = await Preferences.get('float_window_last_position', '') as string
if (!value) {
return { x: 0, y: 120 }
}
const pos = JSON.parse(value) as { x: number; y: number }
const result = this.clampPosition(pos.x, pos.y)
Logger.info('FloatManager', `restore position x=${result.x}, y=${result.y}`)
return result
}
这里有一个我觉得很值得保留的细节:恢复出来的位置,仍然要再过一次 clampPosition。
也就是说,哪怕本地记录过旧、分辨率有变化、窗口尺寸有调整,系统也不会盲目照搬,而是会先把位置修正为当前环境下的合法值。
这就是我特别喜欢的那种“工程感”:不是只让理想路径成立,而是顺手把边缘情况也兜住。
九、主页面需要做的变化其实很少,但职责更清楚了
第二篇并没有让 Index.ets 变得特别重。相反,它只是更清楚地扮演“入口页”的角色。
页面层负责:
- 创建下载任务;
- 在任务创建后打开悬浮窗;
- 订阅任务更新;
- 把当前任务状态传给窗口管理器。
窗口层负责:
- 拖动;
- 坐标更新;
- 停靠;
- 恢复;
- 位置持久化。
也就是说,第二篇以后,页面层与窗口层的责任边界比第一篇清楚了很多。页面不再关心“应该停在哪里”,窗口层也不去操心“最近任务列表怎么渲染”。
我越来越觉得,这种边界感对技术文章特别有价值。因为一旦你把责任线讲清楚,读者抄代码时也不容易把逻辑揉成一团。
十、纯截图最适合展示“拖完以后系统理解了用户意图”
第二篇的无设备边框截图,我最想展示的不是代码细节,而是交互效果本身。
所以这张图里,我让页面上同时呈现下面几层信息:
- 主页面仍然能看到任务入口与最近任务;
- 右上方有已经悬浮出来的下载任务卡片;
- 卡片内提示“已恢复上次悬浮位置”;
- 最近任务列表里的进度与卡片里保持一致。
这样一来,读者不需要读很多字,也能很快明白第二篇到底做成了什么:
这个闪控窗已经不只是“显示一下当前任务”,而是开始拥有自己的位置语义和记忆能力。

十一、类型二综合讲解图在第二篇里承担的是“把逻辑串起来”
我很喜欢在这种连载里保留一张“类型二综合讲解图”,因为有些内容如果全靠文字描述,会稍微慢一点。
第二篇尤其如此。它同时涉及:
- DevEco 里的拖动逻辑代码;
- 模拟器中的实时拖动效果;
- 拖到边缘后的吸附结果;
- 底层流程:拖动开始 → 位置更新 → 边缘检测 → 吸附停靠 → 保存位置 → 下次恢复;
- 真机环境下的最终样子。
如果把这些点分散讲,当然也能说清楚;但用一张综合图做中段总览,会让文章一下形成“总线感”。
这一点对技术社区读者非常友好。因为很多人阅读时不是从头到尾一行不落地看,而是先扫一遍图,看懂脉络后再回来看代码。

十二、调试日志这次比第一篇更重要,因为坐标类问题很难只靠肉眼判断
第一篇里我已经保留了基本日志,到了第二篇,我觉得日志的价值更大了。
原因很简单:一旦进入拖动和位置计算,很多问题光看界面未必能立刻判断。
比如下面这些场景:
- 为什么拖动开始后窗口先抖了一下?
- 为什么贴边时看起来差了十几个像素?
- 为什么恢复位置时总是和上一次有轻微偏差?
- 为什么明明保存成功了,下次打开还是回到了默认位置?
这些问题,只有日志能帮你快速缩小范围。
所以第二篇里,我保留了更偏工程化的日志:
[FloatWindow] drag start, startX=780, startY=120
[FloatWindow] dragging x=812, y=166
[FloatWindow] update position x=812, y=166
[FloatWindow] snap edge=right
[FloatWindow] save position x=964, y=182
[FloatWindow] restore position x=964, y=182
有了这几行以后,你几乎就能把整条交互链路在控制台里重建出来。
我始终觉得,好的技术文章不该只让人“看懂”,还要让人“查得动”。而日志,恰恰就是工程文章里最容易提升可复现性的细节之一。
十三、第二篇我会怎么验收:不是看它会不会动,而是看它动得是不是稳定
如果把这篇文章当成一次功能提交,我不会只在意“拖动成功了没有”。因为很多拖拽功能都能在表面上做出一个会动的效果,但是否稳定、是否可预期、是否便于继续扩展,才决定了这一步到底有没有做扎实。
所以我给第二篇准备了一份很实际的验收清单。
1)拖动起点是否准确
按下悬浮窗以后,第一次移动不应该出现明显跳点。如果一拖就偏移,往往说明开始坐标、窗口原始坐标或者位移计算有问题。
2)移动过程中是否持续跟手
用户拖动时,窗口应该稳定贴着手势走,而不是忽快忽慢,也不应该出现一段时间更新、一段时间停顿的感觉。哪怕现在还没上更细的插值动画,基础跟手感也要稳定。
3)边界约束是否生效
无论往左、往右、往上还是往下拖,窗口都不该跑出可视范围。这一点听起来基础,但只要有一个方向没有约束好,用户就可能把窗口拖到一个很难再找回来的位置。
4)吸附结果是否符合直觉
靠近左边就贴左,靠近右边就贴右,不应该出现“明明靠右却又弹回中间”这种反直觉现象。吸附阈值也不能太大,不然用户只是路过边缘,窗口却被强行带走。
5)恢复位置是否连续
关闭再打开,或者下一次重新进入页面,悬浮窗最好出现在用户刚刚放置过的位置附近,而不是每次都像第一次使用一样回到固定初始点。只有这样,位置记忆这件事才真正对体验有价值。
6)主页面状态与悬浮窗是否仍然同步
第二篇虽然重点在位置,但别忘了它建立在第一篇的共享状态链路上。所以哪怕拖动、吸附、恢复都做完了,任务进度、暂停状态、关闭动作依然必须保持主页面和悬浮窗同步。
我觉得,这套验收思路很适合社区文章。因为它不是抽象原则,而是读者自己跟着做时就能马上检查的一组具体标准。
十四、第二篇我提前处理了两个边界问题,这能让后面继续写时少很多返工
做完主链路以后,我还顺手想清楚了两个边界问题。它们在第二篇里不一定需要大量代码,但最好现在就把语义立住。
1)拖动结束后,到底保存“吸附前位置”还是“吸附后位置”
我最后选的是:保存吸附后位置。
原因很简单,用户最终看到并接受的位置,是吸附后的停靠位置,而不是手指松开瞬间那个中间坐标。如果保存前者,下次恢复出来的位置才会和用户的主观记忆一致。
2)恢复位置时,如果屏幕环境变化了怎么办
比如平板横竖切换、窗口尺寸调整、可用区域变化,这些情况都可能让旧位置不再适合当前界面。所以我在恢复时再次走一遍 clampPosition,本质上就是在做一次二次兜底:
- 先尊重用户之前保存过的位置;
- 再根据当前设备条件把它修正为合法值。
我很喜欢这种处理方式。它不会让体验显得死板,也不会让历史位置成为当前界面的负担。
十三、实机图里我最在意的是“轻量浮层仍然有秩序感”
第二篇最后的实机图,我没有让场景过于复杂,而是保持一种比较温和、真实的桌面环境。原因很简单:我想把重点留给屏幕里的那个小窗。
在真机环境下,我最在意两件事:
1)悬浮窗仍然轻
它应该是一个辅助入口,而不是把主界面压得喘不过气的大面板。
2)悬浮窗已经有秩序
哪怕用户已经拖动过、停靠过,它也仍然是整洁的、稳定的、可以一眼理解的。
这其实就是第二篇想达到的一个很朴素的结果:让自由悬浮不等于自由散乱。

十五、第二篇做完以后,这个 Demo 真正进入“能继续长大”的阶段了
如果回头看第一篇和第二篇之间的差别,你会发现变化不只是多了一套坐标逻辑,而是整个 Demo 的气质开始不同了。
第一篇更像在证明:
- 闪控窗能打开;
- 任务状态能同步;
- 主页面和悬浮层之间可以共享一份数据。
第二篇则开始证明:
- 这个悬浮入口是可操作的;
- 它能跟着用户习惯移动;
- 它能被约束在合适边界;
- 它能记住用户放过它的位置。
这四点一旦成立,轻任务悬浮台就不再只是“一个能展示任务的小卡片”,而更接近“一个真的可以被长期使用的悬浮任务入口”。
而这恰好为第三篇铺好了路。
因为接下来,当位置、吸附、恢复这些基础都稳定以后,我们就有资格继续往更像 HarmonyOS 体验的方向推进:
- 如果用户暂时不想看到完整卡片,能不能先收纳?
- 能不能暂存到侧边,等需要时再恢复?
- 被收起以后,任务状态还能不能继续保持?
这些问题,正好就是第三篇该接手的部分。
十六、本文小记
我很喜欢第二篇这种“看起来只是补细节,实际上在补基础能力”的感觉。
很多时候,技术文章最容易犯的错,是急着往后讲新功能,却没把当前这一步真正做扎实。闪控窗这个主题尤其如此。因为它表面上只是一个小窗,实际上它背后牵涉的是位置、状态、边界、交互和生命周期。
第二篇没有去追复杂场景,而是老老实实把“拖动—约束—吸附—恢复”这条路径做顺。我反而觉得,这正是让这个系列开始站住脚的关键一步。
如果要用一句话总结这一篇,我会写成:
闪控窗真正好用的开始,不是它能被打开,而是它开始理解用户希望它停在哪里。
这就是这一篇最核心的收获。
更多推荐




所有评论(0)