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()) }