Files
FamilyBook_harmonyos/entry/src/main/ets/common/FbScroll.ets
T
laoyang 1a67247407 v60b:所有弹框统一主题色描边环 + 编辑改居中弹框 + 修名册下拉回弹吃掉悬浮栏
1)9 个浮层统一描边环(抽组件,不复制 8 遍)
   新增 components/FbSheetRing.ets:环宽 FB_RING_W=2 全工程唯一一处;
   实现是"外层 Column 铺色 + padding",不是 border(border 画在盒子内侧);
   外圆角 = 内圆角 + 环宽。简要卡/详情/编辑/日程详情/日程编辑/观影详情/影院/
   日程设置/模块设置全部改用;各浮层的环色 = 所属模块的主题色。

2)编辑从"贴底满宽 + 只有上圆角"改成居中弹框
   人员编辑与日程编辑:Stack 对齐改 Center,卡片 92% 宽 + 四角圆 + 环;
   constraintSize 留在卡片自己身上(挪到外层会让内层 Scroll().layoutWeight
   拿到 0 高度,资料区塌成一条线)。日程的日期时间选择弹层、两个删除确认框
   一并套环,避免同一次交互里出现两种形态。

3)修名册下拉到头回弹后悬浮按钮消失
   EdgeEffect.Spring 在越界松手后把内容弹回,每帧 offset>0 被当成"用户在往上滑"
   ⇒ 收起悬浮栏。判据 = 手指已抬起 + 最后一次方向是下滑 + 抬手不到 500ms;
   拦截必须排在 offset >= HIDE_AT 快路径之前(回弹第一帧位移常 >= 12vp)。

4)真机截图暴露的两个观感问题(静态检查全绿也照错)
   - Scroll().layoutWeight(1) 让短内容也撑满 maxHeight ⇒ 卡片空半屏
     (日程详情/模块设置),改为 flexShrink(1);内容本来就长的浮层不动。
   - 人员编辑"虚拟人物(还没弄清真实身份)"被卡片裁掉半句:两个小开关从
     并排 Row 改为 Flex(wrap) 换行,间距改走 margin。

验证:ArkTS 守卫 444/0;反向验证 17/17;真机 _fb_v60b_verify.py 11/0,
且关掉回弹拦截重编重装后该用例确实变红(证明测试不是假绿);
9 个浮层逐个真机截图确认。
2026-10-19 08:29:44 +08:00

281 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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();
}
}