跨端: 统一顶栏组件 + 按压反馈(真跑通)+ 滑动判定从"时长门"改"速度门"

用户四条意见,逐条都是真问题,且**两条是我写完没生效**:
  ① 「所有的顶栏都应该应用玻璃圆框效果」② 「抽象一个统一的顶栏组件出来吧」
  ③ 「你不觉得发件箱太大了吗」④ 「各个组件带响应点击、滑动等的动画了吗」
  ⑤ 「还有转场动画呢?」⑥ 「还有其他行为都要一一对齐,例如邮件展示页面」

★ ① ② 统一顶栏 `AppHeader`
  改造前**六处各写各的**:`AdminUsersPage`(40×40 返回键+16 号标题)、
  `SettingsPage`(20 号标题+40×40「+」+**实心 Theme.surface**)、
  `MailDetailPage`(36×36 + 14 号标题,行高只有 14px 被裁过)、
  `MainPage.SentTab`/`PermissionTab`(通栏、无圆角)、`CommTabBar`。
  尺寸(36/40、14/16/20)、底色(实心白/透明/无)、返回键(有/无)三类都不一致;
  且四处用 `Text('‹')` 当返回键 —— **用字符当图标**(本仓明令禁止,字形随字体变、
  基线对不齐),而 `ICON_PATHS` 里**早就有** `chevronLeft`。
  ⇒ 抽成 `AppHeader`(返回键 + 标题 + `@BuilderParam` 右侧动作区),
    几何复用底部条常量,材质走 `Theme.navMaterial`。已接入 5 处。
  ★ `topInsetPx` 由调用方传:状态栏避让是**窗口级**事实(属页面),
    组件自己读会变成"每层各加一次"(平板侧栏 83+83 双计就是这么来的)。

★ ③ 发件箱"太大"—— 我的理由错了
  我第一版让 `HEADER_HEIGHT = NAV_BAR_HEIGHT`(56),理由是"顶栏与底栏同在一根
  竖轴上,高度不同会一眼看出来"。**那个理由不成立**:
  底栏是**两行**(图标+文字标签)⇒ 需要 56;顶栏只有**一行标题** ⇒ 56 里一半是空白。
  实测 `Row [277,315,1105,476]` = 161px = 56vp,标题那行只占 54px。
  "两根轴上的条要一样高"是把**对齐**理解成了**等高**。真正要对齐的是**左右留白与圆角**。
  ⇒ `HEADER_HEIGHT = 44`(= 可点区下限,返回键装得下)。

★ ④ 按压反馈 —— 两次失败才跑通,两个坑都记进注释
  · **坑一**:`.attributeModifier()` **一个组件只能挂一个**,链两个是**后者覆盖前者**。
    会话组头卡同时挂了玻璃与按压反馈 ⇒ 只生效一个,**编译器不报错、运行不提示**。
    WebUI 是 CSS,类名天然叠加;ArkUI 是单一插槽,"叠加"必须显式做
    ⇒ 新增 `CompositeModifier`。
  · **坑二**:`applyPressedAttribute` **只对自带按压状态机的组件**(`Button`)回调。
    实测:挂 `Button` 上 → hilog 有输出;挂只有 `.onClick` 的 `Row`/`Column` 上
    → **一次都不回调**(按下与常态逐像素相同 `239,244,255`)。而全仓 117 处
    `onClick` 的主体正是纯 `Row`/`Column` ⇒ 那条路对本仓没用。
    改用 `onTouch` + `animateTo`。
  · 底色用**系统点击效果色** `ohos_id_color_click_effect`(不是我自己挑的):
    第一版用 `Theme.surfaceMuted`,实测只差 `3/3/3`,肉眼看不出 ——
    因为"一块面的颜色"与"按下时叠的提示色"在系统色板里是**两个不同语义**。
  设备验证:`onTouch type=0`(Down) → `type=1`(Up) 成对到达,像素确有位移。

★ ④ 滑动判定:从「时长门」改成「速度门」(**设计错误,不是调参**)
  旧门 `SWIPE_MAX_DURATION_MS = 700`("整个手势超 700ms 就拒")。
  而**时长 = 距离 ÷ 速度** —— 它把两个量混成一个 ⇒ 同样的手速下
  **滑得越远越容易被拒**,屏幕越大越严重(平板同一手势像素更多)。
  设备实测(3184×2232,位移恒 542.6vp):
      600px/s→5909ms | 1200→3161ms | 1800→2006ms | 2500→1429ms | 5000→616ms | 15000→123ms
  ⇒ 旧门意味着**只有 ≥5000px/s 才过**,而那一档重复测试也只有 2/4 成功
    —— 用户报的"滑不动/时灵时不灵"就是这个。
  改 `SWIPE_MIN_SPEED = 0.25`(vp/ms,取自上表两档中间)后实测:
      1200px/s → 0/3(正确拒绝:那是拖动)    2500px/s → **0/4 → 3/3**
      5000px/s → **2/4 → 3/3**
  两条判据跟着改形态(**语义不变**:都是"够快才翻页"):
  · `cross-client-gesture` 断言"算的是 dx/ms"——只断言"有个门"会漏掉这次的错
    (旧的时长门**存在**、常量**有名字**、数值**是正数**,三条全过,而真机拒掉正常滑动);
  · `harmony-logic` 的行为判据加了两条**回归钉**:
    `542.6vp/1429ms`(实测的正常甩动)必须过;
    `1200vp/3000ms`(同速度、更远)也必须过 —— 旧门正是在这里误拒。

★ ⑤ 转场动画:查清了,"不做页面级"是**决定**而不是漏做
  WebUI `index.css:1230` 写明:页面级淡入会让**已在那儿的框架**也一起暗一下
  ("观感还是闪"),且用户 2026-09-14 **亲口否掉过**"整屏一起淡",
  所以那一档整体删掉,只保留**局部**动效。鸿蒙侧当前是:
  · `pushUrl` 推页 → **系统自带的滑动转场**(连拍 8 帧验证:第 3 帧能看到
    写信页正在覆盖发件箱,是真实的横向滑入,不是硬切);
  · 局部入场 → `paneRiseIn` / `menuIn` / `calendarSlide`(已接 12 处)。
  所以"加 pageTransition"反而会与用户当时的否决冲突 —— 这一条**不做**,
  理由记在这里,免得下次又当成遗漏。

判据:files=32 ran=32 checks=519 pass=519 fail=0 red=0;baseline 7/7✓。
This commit is contained in:
2026-09-20 10:51:28 +08:00
parent c0ab3f57b2
commit 6ca0113fd2
10 changed files with 556 additions and 184 deletions

View File

@ -616,7 +616,7 @@ test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,
* 两条都要有:只钉语义的话,一个 `return` 写漏了照样全绿。
*/
const CAL = await import(pathToFileURL(join(HARMONY_ETS, 'model/Calendar.ts')).href);
const { judgeSwipe, SWIPE_MIN_DISTANCE, SWIPE_AXIS_RATIO, SWIPE_MAX_DURATION_MS } = CAL;
const { judgeSwipe, SWIPE_MIN_DISTANCE, SWIPE_AXIS_RATIO, SWIPE_MIN_SPEED } = CAL;
// ① 正常快滑:左滑 = 下一段
const next = judgeSwipe(-200, 10, 120);
@ -640,14 +640,32 @@ test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,
/* 纯纵向必然不翻 */
assert.equal(judgeSwipe(5, -300, 100).turned, false, '纯纵向滚动不得翻页');
// ④ 慢拖不翻页
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS + 1).turned, false,
'超过时间窗 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS).turned, true,
'正好在时间窗内 ⇒ 应翻页');
/*
* ④ 慢拖不翻页 —— **按速度判,不是按时长**。
*
* ★★ 2026-09-20 改:原来是 `judgeSwipe(-300, 0, MAX_DURATION + 1)`,
* 即"时长超窗就拒"。那道门把**距离与速度混成一个量**(时长 = 距离 ÷ 速度),
* 于是同样的手速下滑得越远越容易被拒 —— 大屏上会误拒正常滑动。
* 设备实测(3184×2232、位移恒 542.6vp):旧门 700ms 意味着**只有 ≥5000px/s 才过**,
* 而用户正常甩动大约 2500px/s ⇒ "滑不动/时灵时不灵"。
*
* 现在按速度判,且要看**两侧**:慢的一定拒、快的一定过。
*/
assert.equal(judgeSwipe(-300, 0, 5000).turned, false,
'速度 0.06 vp/ms(300/5000)远低于阈值 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
/* ★ 这条是本次的**回归钉**:2500px/s 档在新门之前是 0/4 成功 */
assert.equal(judgeSwipe(-542.6, 0, 1429).turned, true,
'实测的正常甩动(542.6vp / 1429ms ≈ 0.38 vp/ms)**必须过** —— 旧门在这里判红过');
/* 同一距离、更快 ⇒ 更该过(证明判的是速度而不是距离) */
assert.equal(judgeSwipe(-300, 0, 600).turned, true, '同样 300vp、600ms(0.5 vp/ms)⇒ 应翻页');
/* 同样速度、更远 ⇒ 也该过(旧门会在这里误拒) */
assert.equal(judgeSwipe(-1200, 0, 3000).turned, true,
'同样 0.4 vp/ms 但滑得更远(1200vp)⇒ 仍应翻页(旧门会因时长超窗误拒)');
/* 时长为 0 不能除零,也不能被当成"慢" */
assert.equal(judgeSwipe(-300, 0, 0).turned, true, '时长为 0(同一毫秒)不得除零,应按极快处理');
// ⑤ 阈值必须是正数(0 会让所有手势都翻页)
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MAX_DURATION_MS > 0,
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MIN_SPEED > 0,
'三个阈值都要是正数;倍数还必须 > 1(=1 时斜滑就翻页)');
// ⑥ 不翻页时 delta 必须是 0(否则调用方会拿 0 去翻页)