第一篇里,我给“轻任务悬浮台”做出了一个最小可跑通版本:主页面能创建任务,闪控窗能出现,任务状态可以在主页面和悬浮卡片之间同步变化。那一版的重点是把链路打通,所以我刻意没有把问题铺得太开。

但真正把它当成一个稍微像样的 Demo 再多玩一会儿,很快就会遇到第二层问题:这个窗虽然已经能显示任务状态了,但它还不像一个真正好用的悬浮窗。

原因很简单。第一版的闪控窗更多像一个“被打开的小卡片”,而不是一个“可以自由操作的悬浮任务入口”。用户会马上产生下面这些期待:

  • 我能不能把它拖到自己顺手的位置?
  • 如果我拖到边缘,系统是不是应该帮我处理停靠?
  • 下次再打开时,它能不能记住我上一次放的位置?
  • 如果拖得太靠外,窗口会不会直接跑出屏幕?

这些问题看起来像交互细节,但其实已经开始决定这个 Demo 是“仅仅能跑”,还是“开始有真实体验”。

所以第二篇,我只做一件事:把闪控窗从“能显示”推进到“能自由悬浮,而且悬浮得更稳”。

换句话说,这一篇的核心主题不是新增很多业务功能,而是围绕三个关键词把基础体验补完整:

  1. 拖动:用户可以长按并移动悬浮窗;
  2. 约束:悬浮窗不能乱飞,必须被约束在合理区域内;
  3. 恢复:上一次停留的位置可以被记录和恢复。

这三个词合在一起,才是我理解里的“第二篇真正该补的东西”。

在这里插入图片描述

一、为什么第二篇一定要先处理“自由悬浮”

如果把这套五连载当成一个渐进式工程,而不是五篇彼此独立的文章,那么第二篇的位置其实非常明确:它负责让第一篇做出来的“任务窗”真正长出交互能力。

第一篇解决的是“有没有”;第二篇解决的是“好不好用”。

对闪控窗来说,好不好用最先体现在哪里?我觉得不是样式多精致,也不是动画多炫,而是它是不是顺手。

所谓顺手,至少包括以下几层感受:

  • 用户拖动时,窗口要跟手;
  • 停下时,位置要稳定;
  • 不会因为手滑就飞到屏幕外;
  • 接近边缘时,应该有一种“被系统接住”的感觉;
  • 重新打开时,不应该每次都回到初始角落。

这些感受,放在产品语言里叫交互体验;放在工程语言里,其实就是坐标计算、边界限制、停靠策略和位置持久化。

也正因为如此,我觉得第二篇最适合做“工程味”一点的内容。它不再只是讲一个功能点,而是开始把 UI、状态和坐标逻辑合在一起考虑。

二、第二篇我给自己定的交付目标

为了避免一口气把拖拽、吸附、手势、临时收起、侧边栏暂存全部混在一起,这一篇我还是给自己做了边界控制。

第二篇的交付目标,我只锁定为下面四件事:

  1. 闪控窗可以被按住拖动;
  2. 拖动过程中,位置实时刷新;
  3. 松手后如果靠近左右边缘,自动吸附到合适位置;
  4. 当前停留位置保存到本地,下次打开时恢复。

注意,这里我故意只做“边缘约束 + 边缘吸附 + 位置恢复”,而没有继续往“侧边栏暂存”上走。因为第三篇更适合专门处理那个主题。

如果第二篇就把所有和位置相关的行为一锅端,文章会很散。相反,只围绕“拖动”这条线深挖,逻辑会特别顺:

  • 为什么需要拖动;
  • 拖动状态如何采集;
  • 坐标如何更新;
  • 怎么判断边缘;
  • 怎么决定吸附;
  • 怎么把结果记下来。

我很喜欢这种写法,因为它让文章像一条清晰的调试路径,而不是功能清单。

三、目录结构我没有推倒重来,只是把责任边界继续拉清楚

第二篇的好处是,第一篇已经搭好了主骨架,所以我不需要再回头重做目录。我只是让原有结构更明确一点。

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. 开始拖动:记录起点;
  2. 拖动中:计算位移并刷新位置;
  3. 结束拖动:做边缘判定、吸附和位置保存。

我很喜欢这种阶段化拆分,因为它让复杂感一下就被压下去了。

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 体验的方向推进:

  • 如果用户暂时不想看到完整卡片,能不能先收纳?
  • 能不能暂存到侧边,等需要时再恢复?
  • 被收起以后,任务状态还能不能继续保持?

这些问题,正好就是第三篇该接手的部分。

十六、本文小记

我很喜欢第二篇这种“看起来只是补细节,实际上在补基础能力”的感觉。

很多时候,技术文章最容易犯的错,是急着往后讲新功能,却没把当前这一步真正做扎实。闪控窗这个主题尤其如此。因为它表面上只是一个小窗,实际上它背后牵涉的是位置、状态、边界、交互和生命周期。

第二篇没有去追复杂场景,而是老老实实把“拖动—约束—吸附—恢复”这条路径做顺。我反而觉得,这正是让这个系列开始站住脚的关键一步。

如果要用一句话总结这一篇,我会写成:

闪控窗真正好用的开始,不是它能被打开,而是它开始理解用户希望它停在哪里。

这就是这一篇最核心的收获。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐