2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 家书 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 08:29:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 🔴 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 横向滚动容器上报(挂在**横向** 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();
|
|
|
|
|
|
|
2026-10-19 08:29:44 +08:00
|
|
|
|
// 🔴 先拦"回弹":见 `SPRING_MS` 的说明。
|
|
|
|
|
|
// ⚠️ 必须放在**最前面**(包括快路径之前):
|
|
|
|
|
|
// 回弹的第一帧位移往往就 ≥ `HIDE_AT`,放到后面就来不及了。
|
|
|
|
|
|
if (offset > 0 && isSpringBack()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 03:35:39 +08:00
|
|
|
|
// ⚠️ **不跳过 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
// 触摸方向兜底(内容不足一屏的页面用)
|
|
|
|
|
|
// ============================================================
|
2026-10-19 08:29:44 +08:00
|
|
|
|
// ⚠️ `touchStartY` / `TOUCH_MIN` 已提到文件上方 —— `fbOnScroll` 也要读它们
|
|
|
|
|
|
// (判断"手指在拖"还是"抬手后的回弹")。这里不再重复声明。
|
2026-10-18 03:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 触摸方向兜底:在**页面根容器**挂 `.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;
|
|
|
|
|
|
}
|
2026-10-19 08:29:44 +08:00
|
|
|
|
// 🔴 新一轮手势开始 ⇒ 清掉上一轮的方向残留。
|
|
|
|
|
|
// ⚠️ 不清的后果:上一轮是"往下拉"(`lastDir = -1`),这一轮只点了一下
|
|
|
|
|
|
// (位移 < `TOUCH_MIN`,不更新方向)就抬手 ⇒ 抬手的瞬间窗口又开了
|
|
|
|
|
|
// ⇒ 系统惯性里任何正向位移都被误判成回弹、悬浮栏该收却不收。
|
|
|
|
|
|
lastDir = 0;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
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) {
|
2026-10-19 08:29:44 +08:00
|
|
|
|
// 记下方向:抬手后若出现"相反方向的内容位移"(弹簧回弹),用它来识别
|
|
|
|
|
|
lastDir = 1;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
if (!app.dockHidden) {
|
|
|
|
|
|
app.dockHidden = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// ⚠️ **不更新** touchStartY:否则手指继续上抹时会一直在阈值边缘反复触发。
|
|
|
|
|
|
// 保持不动 ⇒ 判断是"相对起点的总位移",更稳。
|
|
|
|
|
|
} else if (dy >= TOUCH_MIN) {
|
2026-10-19 08:29:44 +08:00
|
|
|
|
lastDir = -1;
|
2026-10-18 03:35:39 +08:00
|
|
|
|
if (app.dockHidden) {
|
|
|
|
|
|
app.dockHidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (t === TouchType.Up || t === TouchType.Cancel) {
|
|
|
|
|
|
touchStartY = -1;
|
2026-10-19 08:29:44 +08:00
|
|
|
|
// 🔴 抬手时刻:之后的 500ms 内,与"最后一次手指方向"相反的滚动
|
|
|
|
|
|
// 会被当作弹簧回弹忽略(见 `isSpringBack`)。
|
|
|
|
|
|
lastUpAt = Date.now();
|
2026-10-18 03:35:39 +08:00
|
|
|
|
}
|
2026-10-19 08:29:44 +08:00
|
|
|
|
}
|