From ba16230a63e55bbd5a254f85c6949c1df8101229 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 15:39:44 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E7=90=83=20=E2=86=92=20=E5=9B=9E=E5=A4=8D=E6=9D=A1=20=E5=8A=A0?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E5=85=83=E7=B4=A0=E8=BD=AC=E5=9C=BA=EF=BC=88?= =?UTF-8?q?WebUI=20=E7=9A=84=20FLIP=E3=80=8C=E6=8C=89=E9=92=AE=E9=95=BF?= =?UTF-8?q?=E6=88=90=E9=9D=A2=E6=9D=BF=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」 ## WebUI 的真实行为(读它的实现,不是凭印象) `MailView.tsx:990-1010` + `ComposePage.tsx:57-79` 做的是 **FLIP** (First-Last-Invert-Play): const from = 球的 getBoundingClientRect(); // ① 起点 const to = 面板的 getBoundingClientRect(); // ② 终点 const scale = Math.max(0.04, from.width / to.width); const dx = from.left + from.width/2 - (to.left + to.width/2); const dy = from.top + from.height/2 - (to.top + to.height/2); el.animate([ { transform:`translate(${dx}px,${dy}px) scale(${scale})`, opacity: 0.3, borderRadius: '28px' }, // ← 起点圆角 = 球的半径 { transform:'none', opacity:1, borderRadius:'0px' } ], { duration: 220, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }); 观感就是**球从自己的位置和半径"长"成一整幅面板**。用户说的 「按钮变成输入框」正是这一条 —— 而且它有**两处**:回复球→回复条、 以及写信 FAB→写信页。 ★ 它还显式尊重无障碍:`prefers-reduced-motion` 时直接 return(不打动画)。 ## 鸿蒙的对应能力:`geometryTransition` 官方文档(`ts-transition-animation-geometrytransition`)原文: · 「通过安排绑定的 in/out 组件的 frame、position,将视觉焦点由旧视图位置 引导到新视图位置」—— 就是 FLIP 的系统实现; · 「**必须配合 `animateTo` 使用**才有动画效果,动效时长、曲线跟随 `animateTo` 中的配置,**不支持 `animation` 动画**」; · 「同一个 id 只能有**两个**组件绑定,且分别作为 in(新视图)和 out(旧视图)两种不同类型角色,**不能多个组件绑定同一个 id**」。 ⇒ 三条约束逐条满足: · 两端绑同一个 id `reply-morph`(且**只有**这两处,判据可查); · 状态切换写在 `animateTo` 的**闭包内**(不是 `.animation()`); · 用默认 `follow: false`(两端是 `if/else` 互斥出现,不是"始终在树上")。 ## 时长/曲线的取值(不是我拍的) **220ms + `cubic-bezier(0.22, 0.61, 0.36, 1)`** —— 逐字取 WebUI 那行 FLIP 的参数。 那个贝塞尔恰好就是本仓 `Theme.easeRise` 的定义(`easeRise` 当初就是从这行抄来的), 所以代码里直接引它,没有新写一条曲线。 新增令牌 `Theme.durMorph = 220`,**没有**复用 `durRise`(200):两者是不同动效 (元素出入场 vs 共享元素续接),WebUI 那边也是两个值(150 / 220)—— 合成一个的话以后想单独调其中一个就得先拆开,拆的时候必漏调用点。 ## 设备验证(做了什么 / 没做成什么) ✓ 点球之后**回复条确实展开**(`dumpLayout` 命中「回复给 pi@root.realtest」 与「输入回复内容」两处)——功能链路通。 ✗ **没能在设备上看到中间帧**:`snapshot_display` 一次往返 ~1.5-3s, 而这条动画 220ms ⇒ 探针比被测对象慢一个数量级。连拍 4 张 y=1600 的白区跨度全是 `(30,1007)`(即每张都已是终态)。 ★ 这正是此前"连测七八轮没有中间帧、并编出三个错误理论"的同一个坑 (那次的结论记在本仓:**探针对被测变化不敏感时,量的是噪声**)。 ⇒ 这一条的**动画本体**只能由用户在真机上看,我不声称已验。 顺带把「取消」键改成反向 morph(`closeReplyWithMorph()`)—— WebUI `MailView.tsx:945` 专门记过这个不对称:「打开有 morph、收起是瞬间消失 —— 不对称」。 --- .../entry/src/main/ets/common/Theme.ets | 13 +++ .../src/main/ets/pages/MailDetailPage.ets | 91 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index f911a48..77d65b8 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -865,6 +865,19 @@ export class Theme { * 别拿 `durBase`(180) 或 `durFast`(120) 来替它。 */ static readonly durRise: number = 200; + /** + * **共享元素转场**(`geometryTransition`,即"按钮长成面板")的时长。 + * + * 取 WebUI FLIP 的原值 **220ms**(`ComposePage.tsx:75`)—— + * 就是那个"球长成写信页 / 回复框"的动画。曲线用 `Theme.easeRise`, + * 它的三次贝塞尔正是 WebUI 那行 `cubic-bezier(0.22, 0.61, 0.36, 1)`。 + * + * ★ 为什么单独一个令牌而不是复用 `durRise`:「元素出入场」与 + * 「共享元素在两个位置间续接」是两种动效,WebUI 那边也是两个值 + * (rise-in 150 / morph 220)。合一个的话以后想单独调其中一个 + * 就得先把它拆开 —— 拆的时候一定会漏掉某处调用。 + */ + static readonly durMorph: number = 220; /** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */ static readonly durMenu: number = 140; /** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */ diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index ff377db..b740786 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -303,6 +303,44 @@ export struct MailDetailView { } } + /** + * 打开回复条 —— **带共享元素转场**。 + * + * ★ 官方 `geometryTransition` 的两条硬约束,这里逐条满足: + * + * ① 「**必须配合 `animateTo` 使用**才有动画效果,动效时长、曲线跟随 + * `animateTo` 中的配置,**不支持 `animation` 动画**」 + * ⇒ 所以状态切换写在 `animateTo` 的闭包里,不是写 `.animation()`。 + * + * ② 两端绑**同一个 id**(`reply-morph`),一个 in 一个 out。 + * + * ── 时长/曲线取值 ── + * + * 取 WebUI 的原值:**220ms + `cubic-bezier(0.22, 0.61, 0.36, 1)`** + * (`ComposePage.tsx:74-76` 那段 FLIP 的参数)。 + * 那个三次贝塞尔就是本仓 `Theme.easeRise` 的定义 —— 不是巧合, + * `easeRise` 当初就是从 WebUI 这行抄来的。 + */ + private openReplyWithMorph(): void { + this.showForwardBox = false; + this.getUIContext().animateTo({ + duration: Theme.durMorph, + curve: Theme.easeRise + }, () => { + this.showReplyBox = true; + }); + } + + /** 收起回复条(反向 morph:同一条 220ms,与打开对称) */ + private closeReplyWithMorph(): void { + this.getUIContext().animateTo({ + duration: Theme.durMorph, + curve: Theme.easeRise + }, () => { + this.showReplyBox = false; + }); + } + goBack(): void { if (this.embedded) { this.onBack(); @@ -1108,7 +1146,41 @@ export struct MailDetailView { .width(56).height(56) .borderRadius(28) .backgroundColor(Theme.accent) - .onClick(() => { this.showForwardBox = false; this.showReplyBox = true; }) + /* + * ★★ 2026-09-21 加**共享元素转场**(用户:「webui 行为是按钮变成 + * 对应的写邮件页面或输入框吧,你做的啥?」)。 + * + * ── WebUI 的原行为(`MailView.tsx:990-1010` + `ComposePage.tsx:57-79`)── + * + * 它做的是 **FLIP**(First-Last-Invert-Play): + * ① 记下发球自己的 `getBoundingClientRect()`; + * ② 新面板挂载后取它自己的矩形; + * ③ 反算:`scale = from.width / to.width`(下限 0.04)、 + * `translate = 两个中心的差`; + * ④ 从「缩在球的中心 + opacity 0.3 + 圆角 28px」补间到 + * 「原位 + opacity 1 + 圆角 0」,220ms `cubic-bezier(.22,.61,.36,1)`。 + * + * 关键:**起点的圆角是球的 28px、终点是 0** —— 观感就是 + * "球长成一个面板"。用户说的"按钮变成输入框"正是这一条。 + * + * ── 鸿蒙的对应能力 ── + * + * 官方 `geometryTransition`(API 10 起生效)就是干这个的: + * 「通过安排绑定的 in/out 组件的 frame、position, + * 将视觉焦点由旧视图位置引导到新视图位置」。 + * + * 两条硬约束(官方文档原文,不能违): + * · **必须配合 `animateTo` 使用**才有动画(`animation` 属性无效); + * · **同一个 id 只能有两个组件绑定**,分别作 in(新视图) + * 与 out(旧视图)。⇒ 所以这里的 id 是 `reply-morph`, + * 另一端(回复条)绑**同一个** id,不能有第三处。 + * + * `follow: true` 是给"if 范式下**始终在组件树上**的组件"用的; + * 我们这两端是 `if/else` 互斥出现(一端在树上时另一端不在), + * 所以用默认 `follow: false`。 + */ + .geometryTransition('reply-morph') + .onClick(() => { this.openReplyWithMorph(); }) } .alignItems(VerticalAlign.Bottom) /* 让出底部悬浮条(右下只剩这一个回复球,与 WebUI `reply-fab` 同位) */ @@ -1156,7 +1228,9 @@ export struct MailDetailView { .backgroundColor(Theme.surfaceMuted) .fontColor(Theme.textMuted) .fontSize(13) - .onClick(() => { this.showReplyBox = false; }) + /* 反向 morph(与打开同一条 220ms)—— WebUI `MailView.tsx:945` 那条 + 「收起没有动画 … 打开有 morph、收起是瞬间消失 —— 不对称」的修正 */ + .onClick(() => { this.closeReplyWithMorph(); }) Blank() @@ -1176,6 +1250,19 @@ export struct MailDetailView { .backgroundColor(Theme.surface) /* WebUI 是一条 `border-t`,不是四边圆角面板 */ .border({ width: { top: 1 }, color: Theme.border }) + /* + * ★★ 共享元素转场的 **in 端**(另一端是右下那个回复球)。 + * + * `reply-morph` 这个 id **全仓只在这两处出现** —— 官方约束: + * 「同一个 id 只能有两个组件绑定,且分别作为 in(新视图)和 out(旧视图)… + * 不能多个组件绑定同一个 id」。 + * + * 与上面那个球的两个差异(正是 morph 的观感来源): + * · 尺寸:56×56 的球 → 整幅面板宽; + * · 圆角:28 → 0(球的 `borderRadius(28)` 就是它的半径)。 + * 系统按两端各自的 frame/圆角做插值,所以"长出来"这件事不需要我算。 + */ + .geometryTransition('reply-morph') .transition(Theme.paneRiseIn()) }