跨端: 回复球 → 回复条 加共享元素转场(WebUI 的 FLIP「按钮长成面板」)
用户 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、收起是瞬间消失 —— 不对称」。
This commit is contained in:
@ -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** */
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user