From 6f1b4352cdbfeba4cb6c5948d3c3efd2f5ece6f9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 02:19:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E5=9B=9E=E5=A4=8D/?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E6=94=B9=E5=86=85=E8=81=94=E5=BA=95=E6=A0=8F?= =?UTF-8?q?=20+=20=E5=85=A5=E5=9C=BA=E5=8A=A8=E7=94=BB=E5=AF=B9=E7=A7=B0?= =?UTF-8?q?=E5=8C=96=EF=BC=88=E7=85=A7=E9=B8=BF=E8=92=99=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E7=BA=A0=E6=AD=A3=E4=B8=89=E5=A4=84=E8=AF=AF=E5=88=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「点击回复按键与新建邮件部分的动画与 webui 不一致,动画不符合鸿蒙视觉 要求」。两个问题是分开的:结构是覆盖式弹层 vs WebUI 的内联底栏;动画则是我 单方面发明的不对称过渡 + 150ms 低于鸿蒙规范下限。 ## 结构:覆盖式弹层 → 底部内联条(回复 / 转发) WebUI `MailView.tsx:410/1057` 的 `ReplyBar`/`ForwardBar` 是 `border-t` 分出的 **内联底栏**,与正文并列(正文 `flex-1 overflow-y-auto` 保持可见可滚),高度由 内容决定。我们原先是整屏遮罩 + `height('60%')` + `position({x:0,y:0})`。 三条用户可感知的差异:弹层盖住正文(写回复时看不到原文)/固定 60% 高(写一行 也占半屏)/遮罩整屏变暗。结构不用动外层 —— 原版那两处本来就是正文 Stack 的 **兄弟**(同在 `Column` 里 ⇒ 本来竖直排列),错只错在给条加了遮罩/定高/绝对定位。 ## 动画 ① `paneRiseIn()` / `calendarSlide()` 去 `asymmetric`,改**对称**。 WebUI 是 `animation: rise-in 150ms … both` —— `both` 就是进出同一条关键帧。 我原先让出场只做 `opacity` 且更短(120ms),"出现时浮上来、消失时只淡出", 正是"与 webui 不一致"的来源。当初写不对称的理由(换窗格时两层同时半透明会 "闪")只对**换窗格**成立,对回复框/转发条不成立 —— 我把两种场景混用了。 ② `durRise` 150 → **200ms**(用户选定"折中")。WebUI 是 150(web 常规档), 鸿蒙官方「元素淡入/位移进入」建议 **200-300ms**,150 比下限还低 25%。 ## 照文档纠正三处误判(本轮的真正收获) 我为了搞清"为什么动画不播",先后编出过三个错误理论,读文档后逐条推翻: ① **不是 "NavDestination 吃掉子组件的 `.transition()`"**。 实测:`ComposeView` 根上的 `.transition()` 一直在播。我之所以连测七八轮都报 "没有中间帧",是因为**拿平均亮度当探针** —— 白底窗格 50% 透明叠在浅色背景上 平均亮度几乎不变。换成**位移**探针后,立刻看到"整栏下移 300vp 且半透明"的 中间帧。教训:**探针对被测变化不敏感时,量的是噪声**。 ② **不 `customTransition` 也能做**。`NavDestination` 确实有 `customTransition` (API 15+),但它是**整页转场**,我们要的只是内容块的一次上浮淡入。 (顺带记一条:`NavDestinationTransition.curve` 的类型是枚举 `Curve`, 不收 `ICurve` —— 试过用 `curves.cubicBezierCurve` 会编译报错。) ③ **`.opacity()` 在 `NavDestination` 上是生效的**。先前判定"不生效"同样是那个 废探针害的;换 `opacity(0)` 二元判定后整页消失,证明它一直生效。 ## 连带修一个真 bug(判据抓的) 回复/转发改成内联后**失去了"弹层有固定高度"这层键盘保护** —— 官方默认 `KeyboardAvoidMode.OFFSET`(整页上移)会把贴底的「取消/发送/转发」顶出屏幕。 在 `EntryAbility` 里显式设 `RESIZE`(按剩余高度重排)。坑:`@kit.ArkUI` 与全局 作用域各有一个同名 `KeyboardAvoidMode`,**只有前者有 `RESIZE`**。 ## 判据(4 条红全部结算,逐条说明为什么不是放宽) · `harmony-nav` durRise:从"逐字等于 150"改为**区间 200-300**(钉住用户裁定, 退回 150 与写 800 都红,已变异验证)。 · `harmony-nav` asymmetric:**反转**为"不得 asymmetric"(旧断言把上一版设计锁住, 而 WebUI 本来就是对称的)。 · `harmony-admin` 弹层高度:原断言数的形状只属于废弃的覆盖式弹层 → 改为 **新结构下的等价不变式**(键盘避让必须 RESIZE)。这条判据当年抓的是真 bug, 该 bug 换了形态仍在,所以不能简单删。 · `cross-client-theme`:删掉我中途废弃留下的孤儿令牌 `riseCurveEnum`。 新增 4 条变异条目(全部 `红✓`);`mutants=52 ran=52 skipped=0`。 `files=33 checks=530 pass=530 fail=0`,`baseline=7/7✓`。 设备已验:回复/转发确为内联底栏(正文可见、`border-t` 分隔)。 --- client/electron/test/harmony-admin.test.mjs | 74 ++++- client/electron/test/harmony-nav.test.mjs | 60 +++- .../test/mutants/jobs/jobs-nav-contract.json | 63 ++++- .../entry/src/main/ets/common/Theme.ets | 76 +++++- .../main/ets/entryability/EntryAbility.ets | 30 +- .../src/main/ets/pages/MailDetailPage.ets | 256 +++++++++--------- 6 files changed, 379 insertions(+), 180 deletions(-) diff --git a/client/electron/test/harmony-admin.test.mjs b/client/electron/test/harmony-admin.test.mjs index 7f09f4a..1905c70 100644 --- a/client/electron/test/harmony-admin.test.mjs +++ b/client/electron/test/harmony-admin.test.mjs @@ -831,34 +831,78 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被 */ const src = code(join(ETS, 'pages/MailDetailPage.ets')); - /* 找所有"底部弹层"(`borderRadius({ topLeft: ... })` 是这类弹层的标志) */ - const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)]; - assert.ok(sheets.length >= 2, - `详情页应有多个底部弹层(回复 / 转发)—— 实际找到 ${sheets.length} 个`); + /* + * ★★ 2026-09-20 重写:回复/转发**不再是弹层**,改成了内联底栏。 + * + * 用户原话:「点击回复按键与新建邮件部分的动画与 webui 不一致」,并选定 + * `inline` 方案(WebUI 那边 `MailView.tsx:410/1057` 的 `ReplyBar`/`ForwardBar` + * 就是**内联底栏**:`border-t`、与正文并列、高度由内容决定,正文仍可见)。 + * + * 旧断言数"`borderRadius({ topLeft: N })` 形式的底部弹层 ≥ 2 个"—— + * 那个形状**只属于已废弃的覆盖式弹层**。继续要求它等于把上一版设计锁住。 + * + * ★ 但这条判据**原本抓的是一个真 bug**,那个 bug 没有随结构一起消失, + * 只是换了形态,所以判据不能简单删掉,要**换成新结构下的等价不变式**: + * + * 旧形态(覆盖层 `height('60%')`):弹层自己有固定高度,键盘弹起时 + * 内容能重分配 ⇒ 要断"有明确高度 + 说明框 layoutWeight(1)"。 + * + * 新形态(内联底栏,高度由内容决定):**底栏没有固定高度**, + * 于是"按钮被键盘顶出屏幕"的风险改由**整页的键盘避让模式**承担 —— + * 官方默认 `KeyboardAvoidMode.OFFSET` 是"整页上移",页面被推高后 + * 贴底的内容(正是这些底栏按钮)会被顶出可视区。 + * 正解是把避让模式设成 `RESIZE`("the page is resized", + * 按剩余高度重排,贴底元素留在屏内)。 + * ⇒ 新不变式:**必须在 Ability 层显式设成 RESIZE**, + * 否则内联底栏的按钮在键盘弹起后点不到(同一个后果,另一种成因)。 + */ + const ability = code(join(ROOT, 'client/harmony/entry/src/main/ets/entryability/EntryAbility.ets')); + assert.match(ability, /setKeyboardAvoidMode\(\s*KeyboardAvoidMode\.RESIZE\s*\)/, + '★ 内联底栏(回复/转发)没有自己的固定高度,键盘避让必须用 `RESIZE`。\n' + + ' 官方默认是 `KeyboardAvoidMode.OFFSET`(整页上移)—— 页面被推高后,\n' + + ' 贴底的「取消 / 发送 / 转发」按钮会被顶出可视区(旧版覆盖弹层踩过同一个坑,\n' + + ' 当时是靠"弹层有固定高度"绕过的;现在结构变了,要正面解决)。'); + assert.ok(!/KeyboardAvoidMode\.OFFSET/.test(ability), + '★ 不要显式设成 `OFFSET`(那就是"整页上移",正是要避免的那个模式)'); /* - * 逐个弹层:从 borderRadius 往回找它的高度声明。 - * 在 `.borderRadius({ topLeft` 之前的一段里找 `.height(`。 + * 逐个**仍然存在的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。 + * + * ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层),这道检查现在只剩 + * **对话树弹层**一个对象。它仍然该有明确高度 —— 理由没变: + * 尺寸由内容决定的覆盖层,键盘弹起时内容会被挤出可视区。 + * (内联底栏那半改用上面的 `KeyboardAvoidMode.RESIZE` 承担。) */ + const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)]; + assert.ok(sheets.length >= 1, + `详情页应还有覆盖式弹层(对话树)—— 实际找到 ${sheets.length} 个。\n` + + ' 若回复/转发已全变内联底栏,至少对话树那一个还在;一个都没有说明结构被改坏了。'); const noHeight = []; for (const m of sheets) { const before = src.slice(Math.max(0, m.index - 1200), m.index); - /* 最近的 .height(...) 要在这一段里(弹层的修饰符链上) */ if (!/\.height\(('100%'|\d+|'[0-9]+%')\)/.test(before)) { const line = src.slice(0, m.index).split('\n').length; noHeight.push(`MailDetailPage.ets:${line}`); } } assert.deepEqual(noHeight, [], - '★ 底部弹层必须有明确高度 —— 没有高度时它的尺寸由内容决定,' + - '而键盘弹出(默认 `KeyboardAvoidMode.OFFSET`)会把内容整体上移,' + - '**「取消 / 转发」按钮会被顶到键盘下面**(实测截图:按钮点不到)。\n' + + '★ 覆盖式弹层必须有明确高度 —— 没有高度时它的尺寸由内容决定,' + + '内容一多就会被挤出可视区(旧版回复/转发弹层实测过:按钮点不到)。\n' + `缺高度的弹层:\n ${noHeight.join('\n ')}`); - /* ② 说明框用 layoutWeight 而不是固定高度(否则它撑住高度、按钮仍被挤出) */ + /* + * ② 底栏内部各元素用**内容尺寸/百分比**,不得再用 `layoutWeight(1)` 抢空间。 + * + * 旧形态里弹层有固定高度(`height('60%')`),说明框必须 `layoutWeight(1)` + * 才能在键盘顶上来时自己缩短、把按钮留在屏内。 + * 新形态是**内联底栏**(WebUI 同形):高度由内容决定,说明框给**固定高度**、 + * 由外层 `Scroll` 承担超长内容 —— 再挂 `layoutWeight(1)` 反而会在 + * 无固定高度的容器里抢走全部剩余空间(按钮被压没)。 + */ const ta = /TextArea\(\{[\s\S]{0,200}?转发说明[\s\S]{0,400}?\n\s*\}\)([\s\S]{0,300}?)\.onChange/.exec(src); - assert.ok(ta, '要能找到转发弹层的说明框(TextArea)'); - assert.match(ta[1], /\.layoutWeight\(1\)/, - '★ 转发说明框要用 `layoutWeight(1)`(不是固定 `height(N)`)—— ' + - '弹层高度固定后,只有它会伸缩才能把「取消 / 转发」按钮留在键盘之上'); + if (ta) { + assert.ok(!/\.layoutWeight\(1\)/.test(ta[1]), + '★ 内联底栏里的转发说明框不得用 `layoutWeight(1)` —— ' + + '底栏高度由内容决定,抢权重会把「取消 / 转发」按钮压出可视区'); + } }); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 072afdb..f375781 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -398,10 +398,38 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵 const [, riseMs, riseCurve] = webuiRise; assert.equal(riseMs, '150', `WebUI rise-in 实测是 150ms,读到 ${riseMs}ms —— 参照物变了,要重核两端`); - // 鸿蒙的 durRise 必须= WebUI rise-in 的真实时长(不是 --dur-base 那个 180) - assert.match(theme, new RegExp(`durRise:\\s*number\\s*=\\s*${riseMs}\\b`), - `★ paneRiseIn 的时长必须等于 WebUI rise-in 的真实值 ${riseMs}ms。` + - '写 180(=--dur-base)是拿“壁纸淡入的时长”当“面板入场的时长”——那是两块不同的东西。'); + /* + * ★★ 2026-09-20 改:不再逐字要求鸿蒙 == WebUI 的那个数,改为**有依据的区间**。 + * + * 用户裁定(原话:「点击回复按键与新建邮件部分的动画与 webui 不一致,动画不符合 + * 鸿蒙视觉要求」),三个选项里选了 `compromise_200` = **200ms**。 + * 两端各自的依据都成立: + * · WebUI rise-in 实测 150ms —— 它是 Web,150ms 是 web 常规档; + * · 鸿蒙官方「元素淡入/位移进入」建议 **200-300ms** —— 150ms 比下界还低 25%, + * 在 60/120Hz 的移动端会读成"一瞬间跳出来",这正是用户说的"不符合鸿蒙视觉要求"。 + * ⇒ 判据不再钉死某一个数,而是钉**区间**:既拦住退回 150(低于鸿蒙下界), + * 也拦住乱写(>300 会显得拖沓)。参照物仍从 WebUI 源码实时读出,不写第二份。 + */ + const HARMONY_ENTER_MIN_MS = 200; + const HARMONY_ENTER_MAX_MS = 300; + assert.equal(riseMs, '150', `WebUI rise-in 实测是 150ms,读到 ${riseMs}ms —— 参照物变了,要重核两端`); + const riseToken = /durRise:\s*number\s*=\s*(\d+)\b/.exec(theme); + assert.ok(riseToken, '★ 要有 `durRise` 这个入场时长令牌(`paneRiseIn()` 的时长来源)'); + const riseVal = Number(riseToken[1]); + assert.ok(riseVal >= HARMONY_ENTER_MIN_MS && riseVal <= HARMONY_ENTER_MAX_MS, + `★ 入场时长要在鸿蒙官方建议区间 ${HARMONY_ENTER_MIN_MS}-${HARMONY_ENTER_MAX_MS}ms 内,实际 ${riseVal}ms。\n` + + ` 写 ${riseMs}(照抄 WebUI)低于鸿蒙下界 —— 用户 2026-09-20 原话「动画不符合鸿蒙视觉要求」。\n` + + ' 写 180(=--dur-base)则是拿"壁纸淡入的时长"当"面板入场的时长",那是两块不同的东西。'); + if (riseVal !== Number(riseMs)) { + /* + * 偏离 WebUI 数值时必须写明理由。用 `prose()` 读**原文**(含注释)—— + * `theme` 是 `code()` 读的(注释已被剥掉),拿它查注释必然失配。 + */ + const themeRaw = prose(join(HARMONY_ETS, 'common', 'Theme.ets')); + assert.match(themeRaw, /durRise[\s\S]{0,900}?(WebUI|rise-in)/, + '★ 偏离 WebUI 数值时,`durRise` 附近必须有一段注释写明为什么偏离' + + '(否则下一个人只会当它是随手写的数)。'); + } // 曲线:必须与 rise-in 那条**逐字**一致(而不是与 --ease-out-soft 一致) const curveNums = riseCurve.split(',').map(s => s.trim().replace(/\.0+$/, '')); @@ -467,11 +495,29 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵 assert.ok(!/Theme\.easeOutSoft/.test(body), '★ paneRiseIn 里不得出现 `easeOutSoft`(那是 transition 的曲线,不是 @keyframes 的)'); - // transition 构造器存在,且入场/出场**不对称**(同长会闪) + /* + * transition 构造器存在,且**入场/出场对称**。 + * + * ★★ 2026-09-20 反转(原断言要求 `asymmetric`,现要求**不得** asymmetric)。 + * + * 用户原话:「点击回复按键与新建邮件部分的动画与 webui 不一致」。 + * 而 WebUI 那边**本来就是对称**的 —— `index.css:1326/1331/1336` 三条 + * `.rise-in`/`.pane-rise` 全部是 `animation: rise-in 150ms ... both`, + * `both` = 进出都走同一条关键帧。所以"不对称"这件事是**鸿蒙单方面的发明**, + * 它正是"与 webui 不一致"的来源之一。 + * + * 原先写 asymmetric 的理由(注释里记着)是「同长会让新旧两层半透明叠着, + * 看起来像闪一下」。那个担心对**换窗格**成立(两层同时存在), + * 但对**回复框/转发条**不成立 —— 它出现时底下是一直存在的正文,不是"正在消失的另一半"。 + * 用同一个过渡同时服务两种场景,是当初把两件事混了。 + * ⇒ 按 WebUI 对齐:对称。(换窗格若将来发现"闪",应该用各自更合适的过渡去解。) + */ assert.match(theme, /paneRiseIn\(\):\s*TransitionEffect/, '要有 paneRiseIn() 这个过渡构造器'); - assert.match(body, /TransitionEffect\.asymmetric\(/, - '★ 入场/出场必须 asymmetric(出场更快)—— 同长会让新旧两层半透明叠着,看起来像"闪一下"'); + assert.ok(!/TransitionEffect\.asymmetric\(/.test(body), + '★ `paneRiseIn` 不得用 `asymmetric` —— WebUI 的 `.rise-in`/`.pane-rise` 是 `both`(进出同一条),\n' + + ' 不对称是鸿蒙单方面的发明,正是用户说的"与 webui 不一致"。\n' + + ' (若某处**换窗格**确实需要出场更快,给那一处单独的过渡,不要改这个通用构造器。)'); // ② 关键:transition 要挂在 if/else 的**分支根**上 const branchRoots = (mainCode.match(/\}\)\s*\n\s*\.transition\(Theme\.paneRiseIn\(\)\)/g) ?? []).length; diff --git a/client/electron/test/mutants/jobs/jobs-nav-contract.json b/client/electron/test/mutants/jobs/jobs-nav-contract.json index 5e932b6..c896b79 100644 --- a/client/electron/test/mutants/jobs/jobs-nav-contract.json +++ b/client/electron/test/mutants/jobs/jobs-nav-contract.json @@ -1,14 +1,51 @@ [ - {"file":"client/harmony/entry/src/main/ets/pages/MainPage.ets", - "pat":"\\.backgroundBlurStyle\\(Theme\\.navMaterial\\)", - "repl":".backgroundBlurStyle(BlurStyle.COMPONENT_THIN)", - "test":"nav","why":"★ 导航条绕过 Theme.navMaterial 写死档位(判据该红)"}, - {"file":"client/harmony/entry/src/main/ets/pages/MainPage.ets", - "pat":"\\.backgroundBlurStyle\\(Theme\\.navMaterial\\)", - "repl":".backgroundBlurStyle(NAV_MATERIAL[this.bgPlan.blurPx])", - "test":"nav","why":"★ 导航条又开始跟随 bg_blur(方案 (b) 复发,判据该红)"}, - {"file":"client/harmony/entry/src/main/ets/common/Theme.ets", - "pat":"static readonly navMaterial: BlurStyle = BlurStyle\\.COMPONENT_THICK;", - "repl":"static readonly navMaterial: BlurStyle = BlurStyle.NONE;", - "test":"nav","why":"★ navMaterial 变成 NONE(导航条没有材质,判据该红)"} -] + { + "file": "client/harmony/entry/src/main/ets/pages/MainPage.ets", + "pat": "\\.backgroundBlurStyle\\(Theme\\.navMaterial\\)", + "repl": ".backgroundBlurStyle(BlurStyle.COMPONENT_THIN)", + "test": "nav", + "why": "★ 导航条绕过 Theme.navMaterial 写死档位(判据该红)" + }, + { + "file": "client/harmony/entry/src/main/ets/pages/MainPage.ets", + "pat": "\\.backgroundBlurStyle\\(Theme\\.navMaterial\\)", + "repl": ".backgroundBlurStyle(NAV_MATERIAL[this.bgPlan.blurPx])", + "test": "nav", + "why": "★ 导航条又开始跟随 bg_blur(方案 (b) 复发,判据该红)" + }, + { + "file": "client/harmony/entry/src/main/ets/common/Theme.ets", + "pat": "static readonly navMaterial: BlurStyle = BlurStyle\\.COMPONENT_THICK;", + "repl": "static readonly navMaterial: BlurStyle = BlurStyle.NONE;", + "test": "nav", + "why": "★ navMaterial 变成 NONE(导航条没有材质,判据该红)" + }, + { + "file": "client/harmony/entry/src/main/ets/common/Theme.ets", + "pat": "static readonly durRise: number = 200;", + "repl": "static readonly durRise: number = 150;", + "test": "nav", + "why": "★ 入场时长退回 150ms(低于鸿蒙官方 200-300 建议下限,判据该红)" + }, + { + "file": "client/harmony/entry/src/main/ets/common/Theme.ets", + "pat": "static readonly durRise: number = 200;", + "repl": "static readonly durRise: number = 800;", + "test": "nav", + "why": "★ 入场时长写到区间外(800ms 拖沓,判据该红)" + }, + { + "file": "client/harmony/entry/src/main/ets/common/Theme.ets", + "pat": " return TransitionEffect\\.opacity\\(0\\)\\.combine\\(\\n TransitionEffect\\.translate\\(\\{ y: Theme\\.riseInOffset \\}\\)\\n \\)\\.animation\\(\\{ duration: Motion\\.dur\\(Theme\\.durRise\\), curve: Theme\\.easeRise \\}\\);", + "repl": " return TransitionEffect.asymmetric(TransitionEffect.opacity(0).combine(TransitionEffect.translate({ y: Theme.riseInOffset })).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }), 0, 0, 0, 0);", + "test": "nav", + "why": "★ paneRiseIn 改回 asymmetric(与 WebUI 的 both 不一致,判据该红)" + }, + { + "file": "client/harmony/entry/src/main/ets/entryability/EntryAbility.ets", + "pat": "setKeyboardAvoidMode\\(KeyboardAvoidMode\\.RESIZE\\)", + "repl": "setKeyboardAvoidMode(KeyboardAvoidMode.OFFSET)", + "test": "admin", + "why": "★ 键盘避让退回 OFFSET(内联底栏的按钮会被顶出屏幕,判据该红)" + } +] \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index a7d2b53..29a6f14 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -734,8 +734,27 @@ export class Theme { * 这个区分是 2026-09-18 查出来的,之前两者被当成同一个数。 */ static readonly durBase: number = 180; - /** 面板/内容入场 —— WebUI `@keyframes rise-in` 实测 **150ms** */ - static readonly durRise: number = 150; + /** + * 面板/内容入场。 + * + * ★★ 2026-09-20 150 → **200**(用户:「动画不符合鸿蒙视觉要求」,选定"折中 200ms")。 + * + * 两个来源**打架**,这里取了折中: + * · WebUI `@keyframes rise-in` 实测 **150ms**(本仓既有基准) + * · 鸿蒙官方动效规范(developer.huawei.com「动效属性」页): + * 「元素淡入/位移进入 **200-300ms**」+ EaseOut + * 150ms 比鸿蒙建议的**下限还短 25%** —— 在鸿蒙上确实偏"赶", + * 系统的原生页面转场普遍用 250-350ms。 + * + * 取 **200ms(规范区间下限)** 而不是直接上 250ms: + * WebUI 那一侧的值是用户当年**逐个调过**的(09-14 否掉过"整屏淡入"、 + * 09-15 报过"动画卡顿"),一下子拉到 250 会让两端观感差得更远。 + * 200ms 两边都不冒犯,且落在鸿蒙规范区间内。 + * + * ★ 这条**不是**"令牌值"而是"动画值"—— 上面那段注释讲过两者的区别, + * 别拿 `durBase`(180) 或 `durFast`(120) 来替它。 + */ + static readonly durRise: number = 200; /** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */ static readonly durMenu: number = 140; /** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */ @@ -799,12 +818,30 @@ export class Theme { * 见 `MainPage` 的 `calPaneOpacity` / `calPaneShift`。 */ static paneRiseIn(): TransitionEffect { - return TransitionEffect.asymmetric( - TransitionEffect.opacity(0).combine( - TransitionEffect.translate({ y: Theme.riseInOffset }) - ).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }), - TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise }) - ); + /* + * ★★ 2026-09-20 改成**对称**(用户:「点击回复按键…动画与 webui 不一致」)。 + * + * 改之前是 `asymmetric`:入场做 `opacity + translate`(150ms), + * 退场**只做 opacity**(120ms)。于是同一个"回复框"出现时会浮上来、 + * 消失时却只淡出 —— **两头的动作不是同一件事**。 + * + * WebUI 那边是一条 `@keyframes rise-in` 加 `both`: + * animation: rise-in 150ms cubic-bezier(0.22,0.61,0.36,1) both; + * `both` = 进出都用同一条关键帧 ⇒ 天然对称。 + * + * ★ 当初为什么写成不对称:注释里记过理由 ——「ArkUI 是 if/else 换子树, + * 两层会同时半透明地叠着,同长看起来就是闪一下」。这个担心对 + * **换窗格**(通信↔日历那种整块替换)成立;但**对回复框/转发条不成立** —— + * 它出现时底下没有"正在消失的另一半",而是一块一直存在的正文。 + * 我把两种场景混用了同一个过渡。 + * + * ⇒ 现在对称:进出都是 `opacity + translate`、同一条曲线、同一时长。 + * 换窗格那几处如果将来发现"闪",应该用各自更合适的过渡去解, + * 而不是让**所有**用到 rise 的地方一起背这个不对称。 + */ + return TransitionEffect.opacity(0).combine( + TransitionEffect.translate({ y: Theme.riseInOffset }) + ).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }); } /** @@ -837,11 +874,22 @@ export class Theme { * 所以 12% 就是“滑入自身宽度的 12%”,不是拍一个 vp 数(那样在大屏上会变小)。 */ static calendarSlide(forward: boolean): TransitionEffect { - return TransitionEffect.asymmetric( - TransitionEffect.opacity(0).combine( - TransitionEffect.translate({ x: forward ? '12%' : '-12%' }) - ).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise }), - TransitionEffect.opacity(0).animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeRise }) - ); + /* + * ★★ 2026-09-20 改成**对称**(同 `paneRiseIn`,理由见那处)。 + * + * WebUI 这两个类也是 `both`: + * .cal-slide-next { animation: cal-in-next 200ms cubic-bezier(…) both; } + * 即**进出同一条关键帧**。 + * + * 我原来写的不对称里,退场只做 `opacity`(120ms)—— 于是翻月时 + * 新月份"滑进来"、旧月份只是"淡走",一进一出不是同一件事。 + * + * ★ 对称之后,进出的 `x` 是**同一个**(`forward` 已决定方向), + * 所以不会出现"从右边进、从右边出"的别扭感:退出时它往同一侧滑走, + * 与 WebUI 那条 `both` 的行为一致。 + */ + return TransitionEffect.opacity(0).combine( + TransitionEffect.translate({ x: forward ? '12%' : '-12%' }) + ).animation({ duration: Motion.dur(Theme.durCal), curve: Theme.easeRise }); } } diff --git a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets index b8380b7..e590401 100644 --- a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -15,7 +15,15 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { window } from '@kit.ArkUI'; +/* + * ★ `KeyboardAvoidMode` 必须从 `@kit.ArkUI` 引(= `@ohos.arkui.UIContext` 那个)。 + * + * 全局作用域里**也有**一个同名枚举(`common.d.ts`,只有 DEFAULT/NONE 两个成员), + * 它不带 `RESIZE` ⇒ 不引这一条就会报: + * Property 'RESIZE' does not exist on type 'typeof KeyboardAvoidMode'. + * 两个同名枚举撞在一起,是本条最费时的一步。 + */ +import { KeyboardAvoidMode, window } from '@kit.ArkUI'; import { BusinessError } from '@kit.BasicServicesKit'; import { ApiClient } from '../api/ApiClient'; import { PushService } from '../api/PushService'; @@ -127,6 +135,26 @@ export default class EntryAbility extends UIAbility { return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + /* + * ★ 键盘避让模式改成 `RESIZE`(用户 2026-09-20:「点击回复按键与新建邮件部分 + * 的动画与 webui 不一致」→ 连带把回复/转发改成 WebUI 同形的**内联底栏**)。 + * + * 为什么这一行是**必须**的,而不是可选项: + * 官方默认是 `KeyboardAvoidMode.OFFSET` —— 键盘弹起时**整页上移**。 + * 内联底栏(回复框 / 转发条)是贴在内容**最底下**的一块, + * 页面被推高之后它就出了可视区 ⇒「取消 / 发送 / 转发」点不到。 + * + * 旧版是覆盖式弹层(`height('60%')`),弹层自己有固定高度, + * 键盘弹起时内容能在弹层内部重排 —— 所以那时候靠"弹层有高度 + 说明框 + * layoutWeight(1)"绕过去了。**结构一改成内联,那条绕法就失效了**, + * 必须正面解决。`harmony-admin` 那条判据当场抓到了这一点。 + * + * 选 `RESIZE` 而不是 `NONE`:`NONE` 是"不避让",键盘会直接盖住底栏; + * `RESIZE` 才是"按剩余高度重排",贴底元素留在屏内。 + * (`OFFSET_WITH_CARET`/`RESIZE_WITH_CARET` 是 14+ 的"光标移动也触发"变体, + * 我们的底栏只在弹起时关心一次,不需要额外跟随。) + */ + windowStage.getMainWindowSync().getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); /* * ★ 在 loadContent **之后**才配窗口(与示例工程同一位置): * 示例 `BaseAbilityHelper.doOnWindowStageCreate` 在 loadContent 回调里调 diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index a071404..bbf45d3 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -1068,63 +1068,66 @@ export struct MailDetailView { .width('100%').layoutWeight(1) // 回复弹层 + /* + * ── 回复条(**底部内联**,不是弹层)── + * + * ★★ 2026-09-20 重做(用户:「点击回复按键…动画与 webui 不一致」)。 + * + * 改之前是**覆盖式弹层**:整屏遮罩 + `height('60%')` + `position({x:0,y:0})`。 + * WebUI `MailView.tsx:1040` 是**底部内联条**: + * className="rise-in shrink-0 max-h-[min(65vh,…)] border-t …" + * 与正文**并列**(正文在上面 `flex-1 overflow-y-auto`),一条 `border-t` 分隔, + * **正文仍可见可滚**,高度由内容决定。 + * + * ★ 结构上不用改动外层 —— 原版这里就是"正文 Stack 的兄弟", + * 而它们同在一个 `Column` 里 ⇒ **本来就是竖直排列**。 + * 之前的错在于给条加了遮罩/固定高度/绝对定位,把它做成了覆盖层。 + * (我一度以为要再加一层 Column,那是把"叠放"和"竖排"搞混了 —— + * `Stack` 才是叠放,而这里的父级是 `Column`。) + * + * 三条用户能感知的差异(这才是"不是样式细节"的地方): + * · 弹层盖住正文 ⇒ 写回复时看不到原文,想引用一句得先关掉; + * · 固定 60% 高 ⇒ 只写一行也占半屏; + * · 遮罩整屏变暗,而 WebUI 写回复时页面亮度不变。 + */ if (this.showReplyBox) { Column() { - // 遮罩 - Column() - .width('100%').layoutWeight(1) - .backgroundColor(Theme.overlay) - .onClick(() => { this.showReplyBox = false; }) + Text('回复给 ' + this.replyTargetAddress()) + .fontSize(12).fontColor(Theme.textSubtleFor()) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ bottom: 8 }) - // 回复框 - Column() { - Text('回复给 ' + this.replyTargetAddress()) - .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - .margin({ bottom: 12 }) + TextArea({ placeholder: '输入回复内容…' }) + .width('100%').height(96).fontSize(14) + .onChange((v: string) => { this.replyBody = v; }) - TextArea({ placeholder: '输入回复内容…' }) - .layoutWeight(1).width('100%') - .fontSize(14) - .onChange((v: string) => { this.replyBody = v; }) + Row() { + Button('取消') + .width(80).height(36) + .backgroundColor(Theme.surfaceMuted) + .fontColor(Theme.textMuted) + .fontSize(13) + .onClick(() => { this.showReplyBox = false; }) - Row() { - Button('取消') - .width(80).height(36) - .backgroundColor(Theme.surfaceMuted) - .fontColor(Theme.textMuted) - .fontSize(13) - .onClick(() => { this.showReplyBox = false; }) + Blank() - Blank() - - Button(this.sending ? '发送中…' : '发送') - .width(80).height(36) - .backgroundColor(Theme.accent) - .fontSize(13) - .enabled(!this.sending && this.replyBody.length > 0) - .onClick(() => { this.doReply(); }) - } - .width('100%') - .margin({ top: 12 }) + Button(this.sending ? '发送中…' : '发送') + .width(80).height(36) + .backgroundColor(Theme.accent) + .fontSize(13) + .enabled(!this.sending && this.replyBody.length > 0) + .onClick(() => { this.doReply(); }) } .width('100%') - .height('60%') - .padding(16) - .backgroundColor(Theme.surface) - .borderRadius({ topLeft: 12, topRight: 12 }) - /* - * 回复框入场(对齐 WebUI `MailView.tsx:410` 那条 `rise-in`:上浮 4vp + 淡入)。 - * - * ★ 挂在**回复框本身**(下半部那块表面),不是外层遮罩容器: - * WebUI 那处 `rise-in` 也挂在回复框的 div 上,遮罩是立即出现的。 - * 挂在遮罩上会让整个屏幕(含变暗的底层内容)一起位移 —— - * 那看起来是"页面在动",而不是"回复框弹出来"。 - */ - .transition(Theme.paneRiseIn()) + .margin({ top: 8 }) } - .width('100%').height('100%') - .position({ x: 0, y: 0 }) + .width('100%') + /* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */ + .padding({ left: 16, right: 16, top: 10, bottom: 10 }) + .backgroundColor(Theme.surface) + /* WebUI 是一条 `border-t`,不是四边圆角面板 */ + .border({ width: { top: 1 }, color: Theme.border }) + .transition(Theme.paneRiseIn()) } /* @@ -1133,97 +1136,90 @@ export struct MailDetailView { * * WebUI 的字段顺序与占位文案逐条对齐(`MailView.tsx:412-437`)。 */ + /* + * ── 转发条(**底部内联**,与回复条同构)── + * + * ★★ 2026-09-20 与回复条一起重做(同一条用户反馈)。WebUI `MailView.tsx:410` + * 也是底部内联条(`rise-in … border-t`),**不是弹层**。 + * 两者互斥显示 ——「同时开两个输入框会让人不知道自己在写哪个」 + * (WebUI `ForwardBar` 那段注释的原话)。 + * + * 字段顺序与占位文案仍逐条对齐 WebUI(`MailView.tsx:412-437`)。 + */ if (this.showForwardBox) { Column() { - Column() - .width('100%').layoutWeight(1) - .backgroundColor(Theme.overlay) - .onClick(() => { this.showForwardBox = false; }) - - Column() { - Row() { - Text('转发「' + this.subject + '」') - .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - .layoutWeight(1) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - /* 「抄送」是个**折叠开关**(WebUI 同形:默认收起,点它才出现输入框) */ - Text(this.forwardCcOpen ? '收起抄送' : '抄送') - .fontSize(11) - .fontColor(this.forwardCcOpen ? Theme.accentFor() : Theme.textMuted) - .onClick(() => { this.forwardCcOpen = !this.forwardCcOpen; }) - } - .width('100%') - .margin({ bottom: 10 }) - - TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' }) - .width('100%').height(38).fontSize(13) - .onChange((v: string) => { this.forwardTo = v; }) - - if (this.forwardCcOpen) { - TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' }) - .width('100%').height(38).fontSize(13) - .margin({ top: 8 }) - .onChange((v: string) => { this.forwardCc = v; }) - } - - /* - * 说明框用 `layoutWeight(1)` 而不是固定高度:弹层现在有明确高度 - * (`height('60%')`),剩下多少空间它就占多少 —— 这样键盘顶上来时 - * 它**自己会缩短**,把「取消 / 转发」按钮留在屏幕内。 - * 固定 `height(70)` 在键盘弹出后会把按钮挤出视口(第一版就是这个症状)。 - */ - TextArea({ - placeholder: '转发说明(可选,置于引用原文之前;原文将以引用块附在下方)' - }) - .width('100%').layoutWeight(1).fontSize(13) - .margin({ top: 8 }) - .onChange((v: string) => { this.forwardComment = v; }) - - Row() { - Button('取消') - .width(80).height(36) - .backgroundColor(Theme.surfaceMuted) - .fontColor(Theme.textMuted) - .fontSize(13) - .onClick(() => { this.showForwardBox = false; }) - - Blank() - - Button(this.sending ? '转发中…' : '转发') - .width(80).height(36) - .backgroundColor(Theme.accent) - .fontSize(13) - /* 收件人为空时不可点 —— WebUI 同口径(`disabled={busy || !to.trim()}`) */ - .enabled(!this.sending && this.forwardTo.length > 0) - .onClick(() => { this.doForward(); }) - } - .width('100%') - .margin({ top: 12 }) + Row() { + Text('转发「' + this.subject + '」') + .fontSize(12).fontColor(Theme.textSubtleFor()) + .layoutWeight(1) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + /* 「抄送」是个**折叠开关**(WebUI 同形:默认收起,点它才出现输入框) */ + Text(this.forwardCcOpen ? '收起抄送' : '抄送') + .fontSize(11) + .fontColor(this.forwardCcOpen ? Theme.accentFor() : Theme.textMuted) + .onClick(() => { this.forwardCcOpen = !this.forwardCcOpen; }) } .width('100%') + .margin({ bottom: 8 }) + + TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' }) + .width('100%').height(38).fontSize(13) + .onChange((v: string) => { this.forwardTo = v; }) + + if (this.forwardCcOpen) { + TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' }) + .width('100%').height(38).fontSize(13) + .margin({ top: 8 }) + .onChange((v: string) => { this.forwardCc = v; }) + } + /* - * ★★ 2026-09-19 修(真 bug,设备实测撞出来的): - * 第一版这里**没有高度**,只有 `padding(16)` —— 于是弹层的高度 - * 完全由内容决定,而内容一旦被键盘顶起来,`TextArea` 与 - * 「取消 / 转发」两个按钮就**跑到键盘下面**去了(实测截图: - * 只看得见收件人输入框 + 键盘,「转发」按钮点不到)。 + * ★★ 2026-09-20 改:`layoutWeight(1)` → **固定 `height(88)`**。 * - * ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET` - * (整个布局上移),而要让"上移之后按钮仍在屏幕内", - * 弹层必须有**明确的高度**(内容才能在里面重新分配空间)。 + * 原来的 `layoutWeight(1)` 是给"占 60% 屏的弹层"用的 —— 那时要靠它 + * 吃掉剩余空间,才不至于让按钮被键盘挤出视口。现在是**底部内联条** + * (高度由内容决定),`layoutWeight` 在一个"不占剩余空间"的容器里 + * 没有意义,反而会把它拉成 0 或无限。 * - * 与回复框对齐:那里写的是 `height('60%')`,一直没出问题 —— - * 两个弹层本来就应该同构(同一套交互)。 + * 固定 88vp ≈ 4 行说明文字。整条高度因此确定 + * (标题 + 收件人 + 可选抄送 + 说明 88 + 按钮行), + * 键盘顶上来时整条上移,按钮自然还在屏内。 */ - .height('60%') - .padding(16) - .backgroundColor(Theme.surface) - .borderRadius({ topLeft: 12, topRight: 12 }) - /* 同回复框:`rise-in` 挂在**弹层本身**(理由见回复框那段注释) */ - .transition(Theme.paneRiseIn()) + TextArea({ + placeholder: '转发说明(可选,置于引用原文之前;原文将以引用块附在下方)' + }) + .width('100%').height(88).fontSize(13) + .margin({ top: 8 }) + .onChange((v: string) => { this.forwardComment = v; }) + + Row() { + Button('取消') + .width(80).height(36) + .backgroundColor(Theme.surfaceMuted) + .fontColor(Theme.textMuted) + .fontSize(13) + .onClick(() => { this.showForwardBox = false; }) + + Blank() + + Button(this.sending ? '转发中…' : '转发') + .width(80).height(36) + .backgroundColor(Theme.accent) + .fontSize(13) + /* 收件人为空时不可点 —— WebUI 同口径(`disabled={busy || !to.trim()}`) */ + .enabled(!this.sending && this.forwardTo.length > 0) + .onClick(() => { this.doForward(); }) + } + .width('100%') + .margin({ top: 8 }) } - .width('100%').height('100%') - .position({ x: 0, y: 0 }) + .width('100%') + /* `shrink-0` 的对应物:不给 `layoutWeight` ⇒ 高度由内容决定 */ + .padding({ left: 16, right: 16, top: 10, bottom: 10 }) + .backgroundColor(Theme.surface) + /* 同回复条:一条 `border-t` 分隔 */ + .border({ width: { top: 1 }, color: Theme.border }) + .transition(Theme.paneRiseIn()) } } }