/** * 家书 FamilyBook · 滚动方向跟踪(鸿蒙端) * * 🔴 存在的原因(用户 2026-10-13 明确要求): * 「下面四个悬浮的按钮,当往上滑的时候它就消失了。然后整个就是内容。 * 等到从上往下滑的时候,它就又出现了。这个典型的应用呢,是像这个 IT 之家这种形式的。」 * * ⇒ 把「向上滚 / 向下滚」写进全局状态(`FbAppState.dockHidden`), * 由 `Index.ets` 据此把悬浮栏滑走 / 滑回。 * * ⚠️ 为什么**不用** `@ohos.arkui.observer` 的全局 `'scrollEvent'` 观察者: * 它只报 `SCROLL_START` / `SCROLL_STOP`(**只有起止、没有方向**), * 做不了"往上还是往下"(已查 SDK 确认)。 * ⇒ 只能在各页最外层滚动容器上挂 `onDidScroll`(API 12+)。 * 它的 `scrollOffset` 是**每帧的增量**(vp): * · `> 0` = 内容往上走(手指上滑、看后面的内容); * · `< 0` = 内容往下走(手指下滑、回看前面的内容)。 * * ⚠️ 为什么各页要**各写一行** `.onDidScroll(fbOnScroll)`: * ArkUI 没有"全局绊住所有滚动容器"的钩子。逐页显式挂最直白, * 而且哪个页面参与这套行为,看代码就知道(没写这行 = 不参与)。 * * 🔴🔴 2026-10-15 改:**阈值从 48/40 降到 12/8**(真机反馈"其他模块不收起")。 * * 实测症状:**首页/名册能收起,其他模块(任务/资源/藏书/旅程/设置…)不能**。 * 差别在于**内容高度** —— 那几页内容刚好一屏左右,向上滑只有很小一段可滚, * 累积量到不了 48vp ⇒ 永远不触发。 * ⇒ 阈值必须**按"最小可感知滑动"来定**,而不是"一整段内容"。 * 12vp 约等于"手指明确地推了一下",既不会误触、 * 又能在内容只有很短可滚行程时正常触发。 * ⚠️ 收/唤两个阈值**不同**(收 12、唤 8):收起要"更确定"、唤回要"更灵敏"—— * 用户往回翻,本意多半就是要找底部入口。 */ import { fbAppState } from './Store'; /** * 向上滚多少(vp 累计)才**收起**悬浮栏。 * * ⚠️ 别再调大:内容短的那些页面(任务/资源/藏书)可滚行程只有几十 vp, * 阈值一大就永远不会触发(这正是本轮修的 bug)。 */ const HIDE_AT: number = 12; /** * 向下滚多少(vp 累计)才**唤回**悬浮栏。 * * 比 `HIDE_AT` 更小:收起要"更确定"、唤回要"更灵敏"。 */ const SHOW_AT: number = 8; /** * 🔴 2026-10-19 用户:「名册下拉的时候,拉到头,它会**回弹一下**, * 导致下面的悬浮按钮**又消失了**。」 * * 真因(`EdgeEffect.Spring` 的必然结果): * · 手指往下拉(内容往下走)⇒ 每帧 `offset < 0` ⇒ 我们**正确地唤回**悬浮栏; * · 拉到顶之后继续拉 = **越界**,内容停在原位、手指还在动; * · **松手** ⇒ 系统弹簧把内容**弹回去**(内容往上走)⇒ 每帧 `offset > 0` * ⇒ 被当成"用户在往上滑" ⇒ **收起悬浮栏**。 * 用户看到的就是"按钮闪没"。 * * 判据(不用猜,用**手指方向 + 时间**): * · 回弹一定发生在**抬手之后**、且紧跟着一次**下滑**; * · 真正的"往上滑"要么手指还在屏幕上(`touchStartY >= 0`), * 要么上一次手指方向就是往上(`lastDir === 1`)。 * ⇒ 只有"手指已抬起 + 上一次是下滑 + 刚抬手不到 `SPRING_MS`"才拦。 * * ⚠️ 为什么用 500ms:本机弹簧动画(Spring)在真机上约 300–450ms 结束, * 取 500 能把整段回弹盖住;再长就会误伤"下拉之后马上接着上滑"。 */ const SPRING_MS: number = 500; /** * 手指按下位置(**-1 = 当前没有按压**;用哨兵值而不是 null,ArkTS 对可选值更严格)。 * * ⚠️ 提到文件上方(原来是放在触摸段里的):`fbOnScroll` 也要读它判断 * "现在是指针在拖、还是抬手后的惯性/回弹"。 */ let touchStartY: number = -1; /** 一次滑动至少要移动这么多(vp)才认作"有方向意图"(避免把点击当滑动) */ const TOUCH_MIN: number = 16; /** 手指**最近一次**明确方向:1 = 往上滑(内容上移),-1 = 往下滑,0 = 还没动 */ let lastDir: number = 0; /** 最近一次抬手的时间戳(0 = 还没抬过手) */ let lastUpAt: number = 0; /** * 现在收到的"正向位移"(内容往上走)是不是**弹簧回弹**。 * * ⚠️ 只在 `fbOnScroll` 里用;判定条件见 `SPRING_MS` 上方的说明。 */ function isSpringBack(): boolean { // 手指还在屏幕上 ⇒ 用户正在拖,尊重他的方向 if (touchStartY >= 0) { return false; } // 刚刚是"往下拉",且才抬手不久 ⇒ 这一串正向位移是弹簧把内容弹回去 return lastDir === -1 && lastUpAt > 0 && (Date.now() - lastUpAt) < SPRING_MS; } /** * 横向滚动容器上报(挂在**横向** Scroll 上的 `.onDidScroll`)。 * * 🔴 用途(用户 2026-10-15 要求"有横滑组件时优先响应组件、别开抽屉"): * 记一个时间戳,抽屉手势结束时会检查"刚刚有没有横滑在为"。 * ⇒ 在标签条上横滑时,抽屉让位,不再"一不小心滑出来"。 * * ⚠️ 与 `fbOnScroll` 的区别:那个是**纵向**滚动(管悬浮栏收起), * 这个是**横向**滚动(管抽屉让位)。两个都挂是正常的。 * ⚠️ 用 `Date.now()` 而不是计数:需要的是"最近有没有"这个时间语义。 */ export function fbOnHScroll(_offset: number, _state: ScrollState): void { fbAppState().hScrollAt = Date.now(); } /** * 判定"刚刚是否有横滑组件在为"。 * * @param withinMs 多久之内算"刚刚"。默认 350ms —— * 横滑停下到手指抬起之间通常有几十到一两百毫秒, * 抽屉的 `onActionEnd` 也在手指抬起那一刻触发 ⇒ 350 足够覆盖, * 又不会把"上一次横滑"误判成这次(用户滑完停一会儿再想开抽屉是正常的)。 */ export function fbHScrollBusy(withinMs: number = 350): boolean { const at: number = fbAppState().hScrollAt; return at > 0 && (Date.now() - at) < withinMs; } /** * 各页最外层滚动容器的滚动回调。 * * 用法(每个模块页的**最外层**滚动容器挂一次): * ```ts * Scroll() { ... } * .width('100%') * .height('100%') * .onDidScroll(fbOnScroll) * ``` * ⚠️ 只挂**最外层**那一个:内层横向滚动(标签条、看板筛选条、账本切换条) * 要挂的是 `fbOnHScroll`(管抽屉让位),**不是**这个 —— * 挂错了会让纵向的累积值被横向滚动污染。 */ export function fbOnScroll(offset: number, state: ScrollState): void { const app = fbAppState(); // 🔴 先拦"回弹":见 `SPRING_MS` 的说明。 // ⚠️ 必须放在**最前面**(包括快路径之前): // 回弹的第一帧位移往往就 ≥ `HIDE_AT`,放到后面就来不及了。 if (offset > 0 && isSpringBack()) { return; } // ⚠️ **不跳过 Idle**:`Fling`(惯性)结束时最后那一下会带 Idle, // 而内容短的页面可能**只在惯性阶段**产生位移 —— 跳过就永远收不起来。 // 但 Idle 的 offset 偶尔是 0(无方向含义),所以下面按 offset 的正负判断, // 0 自然不产生任何影响。 if (offset === 0) { return; } // ---- 方向法(主判据):一帧偏移足够大 ⇒ 直接按方向切 ---- // 🔴 为什么不只靠累积:内容短的页面可滚行程小、帧数少, // 累积还没到阈值滑动就结束了(旧实现就是这么漏掉的)。 // 这里加一条"单帧方向足够明确就直接切"的快路径。 if (offset >= HIDE_AT) { app.scrollAcc = HIDE_AT; if (!app.dockHidden) { app.dockHidden = true; } return; } if (offset <= -SHOW_AT) { app.scrollAcc = -SHOW_AT; if (app.dockHidden) { app.dockHidden = false; } return; } // ---- 累积法(兜底):小步慢滑时把微小增量攒起来 ---- app.scrollAcc += offset; if (app.scrollAcc >= HIDE_AT) { // 钉在阈值上(滞回):反向只需走 SHOW_AT 就能切回来 app.scrollAcc = HIDE_AT; if (!app.dockHidden) { app.dockHidden = true; } } else if (app.scrollAcc <= -SHOW_AT) { app.scrollAcc = -SHOW_AT; if (app.dockHidden) { app.dockHidden = false; } } } // ============================================================ // 触摸方向兜底(内容不足一屏的页面用) // ============================================================ // ⚠️ `touchStartY` / `TOUCH_MIN` 已提到文件上方 —— `fbOnScroll` 也要读它们 // (判断"手指在拖"还是"抬手后的回弹")。这里不再重复声明。 /** * 触摸方向兜底:在**页面根容器**挂 `.onTouch(fbOnTouch)`。 * * 🔴 为什么需要它(2026-10-15 真机反馈): * 「其他模块往上滑动的时候,那四个悬浮条却没有隐藏。」 * 实测根因:**任务/资源/藏书/设置这些页面内容不足一屏,压根滚不动** * ⇒ 挂在外层滚动容器上的 `onDidScroll` **一次都不会触发** * ⇒ 悬浮栏永远收不起来(首页/名册内容多,所以正常)。 * * ⇒ 用**触摸位移方向**做兜底:手指从下往上抹(`dy < 0`)就收起、 * 从上往下抹就唤回。它不依赖"内容能滚"这件事。 * * ⚠️ 与 `fbOnScroll` 的关系:**两者都放行、互不冲突**(同一个 `dockHidden` 状态, * 同向重复设置是无害的幂等操作)。滚动能触发时以 `fbOnScroll` 为主 * (它更精确:区分了内容位移与手指位移)。 * ⚠️ 阈值 `TOUCH_MIN = 16vp` 是"手指位移",比滚动阈值大一些 —— * 因为手指位移总是大于等于内容位移(滚到头时内容不动、手指还在动), * 取小了会在**点击**时也触发。 * ⚠️ 用 **`windowY`**(窗口坐标)而不是 `y`(组件内坐标): * `y` 是相对**当前组件**的,手指移出组件后不再更新(可能拿到 0)。 * `windowY` 是窗口坐标、全程连续。 * ⚠️ `globalDisplayY` 在 SDK 里是**可选字段**(`number | undefined`), * ArkTS 会报类型错 ⇒ 一律用必填的 `windowY`。 */ export function fbOnTouch(event: TouchEvent): void { const app = fbAppState(); const t: TouchType = event.type; if (t === TouchType.Down) { const arr: TouchObject[] = event.touches; if (arr.length > 0) { touchStartY = arr[0].windowY; } // 🔴 新一轮手势开始 ⇒ 清掉上一轮的方向残留。 // ⚠️ 不清的后果:上一轮是"往下拉"(`lastDir = -1`),这一轮只点了一下 // (位移 < `TOUCH_MIN`,不更新方向)就抬手 ⇒ 抬手的瞬间窗口又开了 // ⇒ 系统惯性里任何正向位移都被误判成回弹、悬浮栏该收却不收。 lastDir = 0; return; } if (t === TouchType.Move) { if (touchStartY < 0) { return; } const arr: TouchObject[] = event.touches; if (arr.length === 0) { return; } const dy: number = arr[0].windowY - touchStartY; // dy < 0 ⇒ 手指往上抹 ⇒ 内容往上走 ⇒ 收起 if (dy <= -TOUCH_MIN) { // 记下方向:抬手后若出现"相反方向的内容位移"(弹簧回弹),用它来识别 lastDir = 1; if (!app.dockHidden) { app.dockHidden = true; } // ⚠️ **不更新** touchStartY:否则手指继续上抹时会一直在阈值边缘反复触发。 // 保持不动 ⇒ 判断是"相对起点的总位移",更稳。 } else if (dy >= TOUCH_MIN) { lastDir = -1; if (app.dockHidden) { app.dockHidden = false; } } return; } if (t === TouchType.Up || t === TouchType.Cancel) { touchStartY = -1; // 🔴 抬手时刻:之后的 500ms 内,与"最后一次手指方向"相反的滚动 // 会被当作弹簧回弹忽略(见 `isSpringBack`)。 lastUpAt = Date.now(); } }