Files
FamilyBook_harmonyos/entry/src/main/ets/common/FbScroll.ets
T

213 lines
8.9 KiB
Plaintext
Raw Normal View History

/**
* 家书 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;
/**
* 横向滚动容器上报(挂在**横向** 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();
// ⚠️ **不跳过 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;
}
}
}
// ============================================================
// 触摸方向兜底(内容不足一屏的页面用)
// ============================================================
/** 手指按下的位置(**-1 = 当前没有按压**;用哨兵值而不是 null,ArkTS 对可选值更严格) */
let touchStartY: number = -1;
/** 一次滑动至少要移动这么多(vp)才认作"有方向意图"(避免把点击当滑动) */
const TOUCH_MIN: number = 16;
/**
* 触摸方向兜底:在**页面根容器**挂 `.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;
}
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) {
if (!app.dockHidden) {
app.dockHidden = true;
}
// ⚠️ **不更新** touchStartY:否则手指继续上抹时会一直在阈值边缘反复触发。
// 保持不动 ⇒ 判断是"相对起点的总位移",更稳。
} else if (dy >= TOUCH_MIN) {
if (app.dockHidden) {
app.dockHidden = false;
}
}
return;
}
if (t === TouchType.Up || t === TouchType.Cancel) {
touchStartY = -1;
}
}