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 个浮层逐个真机截图确认。
281 lines
12 KiB
Plaintext
281 lines
12 KiB
Plaintext
/**
|
||
* 家书 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();
|
||
}
|
||
}
|