From 0f22aef6894709b6ab7dfa965791c9636498c0e9 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 6 Nov 2026 10:18:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B9=E6=A1=86=E5=8A=A8=E7=94=BB=E4=B8=9D?= =?UTF-8?q?=E6=BB=91=E5=8C=96=EF=BC=9A=E5=85=A5=E5=9C=BA=E5=8A=A0=E4=BD=8D?= =?UTF-8?q?=E7=A7=BB+=E5=BC=B9=E7=B0=A7=E6=9B=B2=E7=BA=BF=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=8A=A0=E8=BD=BD=E6=80=81=E6=89=93=E6=96=AD=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:首页,弹出所有弹框,动画,都要改一下,改的跟丝滑流畅, 尤其是观影,基本上没动画,都需要修改。 先核实:把时长临时放大到 3s 后连拍,遮罩 246→210→170→153→148 逐帧压暗, 说明**动画确实在播**。所以不是没接上,而是动效本身太弱: 220ms 太短、只有 0.94→1 缩放(差 6%,且无方向感)、FastOutSlowIn 落定硬停、 进出同长。 四处改动: ① 入场加纵向位移 SHEET_RISE_VP=22 —— 竖向是人眼最敏感的方向, 纯缩放读不出从哪来;22vp 是看得出从下面来但不觉得它飞了的量。 ② 时长分离:卡片 320/200、遮罩 240/160(出场必须比入场快, 用户已决定关闭,等待就是拦他);缩放起点 0.94→0.90。 ③ 曲线换物理弹簧 curves.springCurve(0, 1, 228, 26)(末段自然收敛)。 ⚠️ 先写的 Curve.springMotion 编译失败——本工程 SDK 6.1.1 里没这个 API, 技能包速查表也把它列在高频臆造 API里,是我没照表走。 ④ 观影/日程详情卡加载态:**这是观影没动画的真凶** —— 卡片高度按内容定,加载态只有一行字(~100px),详情回来瞬间撑到 72% 屏高, 320ms 的入场动画只播了前 1/3 就被高度突变打断。 改法:加载态放固定 460vp 骨架屏(FbEventSheet 同样问题,一并修 300vp)。 用固定 vp 而非 %:builder 在 Scroll 里,百分比拿不到基准。 验证(真机):放大时长连拍 N2 卡片顶边 y=1073 → N3 y=1011, 上移 62px≈20vp,证实位移生效;临时给接口加 1.5s 延迟后抓图, 骨架屏(海报位+4 条文字条)正确撑住卡片高度;临时代码已撤除(grep 0 处)。 连开合 3 轮均正常、无 JS 崩溃、进程存活。 hvigorw assembleHap BUILD SUCCESSFUL。 FbAnim 是全站唯一动画入口,98 处引用 / 14 个页面,全部同时生效。 --- entry/src/main/ets/common/FbAnim.ets | 220 +++++++++++++++--- .../src/main/ets/components/FbEventSheet.ets | 22 +- .../ets/components/FbMovieDetailSheet.ets | 39 +++- 3 files changed, 242 insertions(+), 39 deletions(-) diff --git a/entry/src/main/ets/common/FbAnim.ets b/entry/src/main/ets/common/FbAnim.ets index 26b8d4f..c72979e 100644 --- a/entry/src/main/ets/common/FbAnim.ets +++ b/entry/src/main/ets/common/FbAnim.ets @@ -4,69 +4,231 @@ * 🔴 用户 2026-10-19:「做几个动画,例如,点击名册列表,应该有一个过渡动画出现弹框, * 点击详情,框变大了,也应该有一个过渡动画,点编辑,也有动画。」 * + * 🔴 用户 2026-11-06:「首页,弹出所有弹框,动画,都要改一下,改的跟丝滑流畅, + * 尤其是观影,基本上没动画,都需要修改。」 + * + * ⇒ 现象**核实过**(把时长临时放大到 3s 后连拍,遮罩 246→210→170→153→148 逐帧压暗), + * 动画确实在播。所以"基本没动画"不是"没接上",而是**动效本身太弱、太短**: + * · 220ms 太短 —— 点击后的注意力回升期还没结束,动画就已经播完了; + * · 只有「淡入 + 0.94→1 缩放」:缩放是最弱的动效语言(只差 6%), + * 而且**没有方向**,人眼读不出"它从哪来"; + * · `FastOutSlowIn` 是固定时长缓动,落定那一下仍然是"停住", + * 缺少弹簧收敛的质感 ⇒ 观感不"丝滑"。 + * + * 本次改造的三件事: + * ① 入场加**自下而上的位移**(22vp)—— 给人眼最敏感的纵向语义; + * ② 缩放起点从 0.94 放到 0.90,配合更长的 320ms,让"长出来"看得出来; + * ③ 曲线换成 `curves.springCurve(0, 1, 228, 26)` —— 轻微过冲后自然收敛, + * 这才是"丝滑"的来源(细节见 `fbSheetTransition` 的注释)。 + * ④ 顺带修正「进出同长」:入场 320 / 出场 200,遮罩 240 / 160。 + * * 为什么**统一抽在这里**(而不是每个浮层自己写一遍 `.transition(...)`): - * · 本工程有 9 个页面内浮层 + 一堆 bindSheet,逐个手写必然漂移 - * (时长/曲线/缩放比各不一样,动画看起来会"不是一个应用"); + * · 本工程有 9 个页面内浮层 + 一堆 bindSheet、全站 98 处引用, + * 逐个手写必然漂移(时长/曲线/缩放比各不一样,动画会"不是一个应用"); * · 全应用的浮层**必须同一种出入场**:这是"同一个系统"的观感来源之一。 * ⚠️ 与 `FbSheetRing` 的分工:那个管**静止时的样子**(环/圆角/阴影), * 这个管**动起来的样子**(出现/消失)。两者都只有一处定义。 * * 技术选型(为什么用 `TransitionEffect` 的**自带 animation**): * · `.transition(effect)` 挂在 `if` 分支里的组件上,分支切换时自动播出入场; - * · `effect.animation({...})` 让**动画参数挂在效果自己身上** ⇒ 不需要 - * 调用方再包一层 `animateTo`(漏包一处就变成"啪一下出现",而且很难查)。 - * ⚠️ `.combine()` 要求**两边都写 animation**:SDK 原文 + * · `effect.animation({...})` 让动画参数挂在效果自己身上 ⇒ 不需要调用方 + * 再包 `animateTo`(漏包一处就变成"啪一下出现",而且很难查)。 + * ⚠️ `.combine()` 之后**必须再挂一次 animation**:SDK 原文 * (`common.d.ts` TransitionEffect.combine 的说明): * "If combine is used for combining transition effects, the animation settings * of a transition effect are not effective for the combined effects." - * ⇒ 所以我们自己在外面再挂一次(`SHEET_CARD` / `SHEET_MASK` 里就是这么做的)。 + * ⚠️ `TransitionEffect.OPACITY` / `.scale()` 这些静态成员**自带默认 1000ms 动画**。 + * 虽然 combine 后外层 animation 会覆盖它,但为了不留"某条路径走默认值"的 + * 隐患,这里每个子效果都用 `withAnim()` 显式挂参数,最后再挂一次。 */ -/** 浮层出入场时长(毫秒)—— 220 是"跟手但不拖沓"的区间(与底栏位移动画同档) */ -const SHEET_MS: number = 220; +import { curves } from '@kit.ArkUI'; -/** 遮罩淡入淡出时长 —— 比卡片略短,让"压暗"先到位、卡片再落定 */ -const MASK_MS: number = 180; +// ============================================================ +// 时长 +// ============================================================ + +/** + * 浮层**入场**时长(毫秒)。 + * + * 为什么从 220 提到 320: + * · 220ms 在连续点击的节奏里"来不及看清就结束了",用户会描述成"没动画"; + * · 320ms 是模态类入场常用的档位,能看清"从哪来、落到哪",又不至于觉得卡; + * · 浮层是**低频重动作**,本来就该比微交互(底栏位移那类 180~220)慢一档。 + */ +const SHEET_IN_MS: number = 320; + +/** + * 浮层**出场**时长(毫秒)—— 明显短于入场。 + * + * ⚠️ 出场必须比入场快,这不是美学偏好而是交互心理: + * 用户点关闭时已经"决定不要这个东西了",任何额外等待都是在拦他; + * 而入场时用户是"想看",多等 100ms 换取动效可读性是划算的。 + * 常见配比就是 入场:出场 ≈ 1.5:1。 + */ +const SHEET_OUT_MS: number = 200; + +/** 遮罩淡入时长 —— 略短于卡片,让"压暗先到位、卡片再落定" */ +const MASK_IN_MS: number = 240; + +/** 遮罩淡出时长 —— 比卡片更快撤走,避免"卡片没了、底下还黑一会儿" */ +const MASK_OUT_MS: number = 160; + +/** + * 卡片入场的位移量(vp)—— 从下方"托起"。 + * + * 这是本次改造的**关键**:纯缩放几乎看不出变化,而竖向位移是人眼最敏感的方向 + * (屏幕上"内容出现"几乎都自带纵向语义)。22vp 是"看得出它从下面来、 + * 但不会觉得它飞了"的量;再大(40+)就成"底部抽屉",与本项目"居中浮窗"不符。 + */ +const SHEET_RISE_VP: number = 22; + +/** + * 入场缩放起点。 + * + * 0.90 而不是原来的 0.94:0.94 只差 6%,配合 320ms 仍偏弱; + * 0.90 能明确表达"它长出来了",又不至于像"小方块弹一下"。 + * ⚠️ 别低于 0.85:卡片是"92% 宽 + 按内容高",缩太多会先看到一块明显更小的 + * 白片,反而像另一个控件。 + */ +const SHEET_SCALE_FROM: number = 0.90; + +// ============================================================ +// 内部:组装工具 +// ============================================================ + +/** + * 浮层进场用的弹簧曲线(轻微过冲后收敛)。 + * + * 🔴 **不要再写 `Curve.springMotion(...)`** —— 那个 API 在本工程的 SDK + * (compatibleSdkVersion 6.1.1 / API 24)里**不存在**,编译直接报 + * `Property 'springMotion' does not exist on type 'typeof Curve'`。 + * 能用的是 `curves.springCurve(velocity, mass, stiffness, damping)`, + * 来自 `@ohos.curves`(`import { curves } from '@kit.ArkUI'`)。 + * + * 参数语义(照 SDK 的 `@ohos.curves.d.ts` 原文): + * · velocity 初速度 —— 用来"接着上一段运动的势头",从静止开始给 0; + * · mass 质量 —— 越大惯性越大、回弹幅度越大、回位越慢; + * · stiffness 刚度 —— 越大越"硬"、回位越快; + * · damping 阻尼 —— 越大振荡次数越少、幅度越小(**不是**阻尼比)。 + * + * 取值 0 → 1 → 228 → 26 的来由(这是 ArkUI 官方示例里那组经典值, + * 观感是"起手跟手、中段快、末段有一次极轻微的回弹就停住"): + * · mass 1 / stiffness 228 决定了固有频率约 15rad/s ⇒ 一个周期 ~420ms, + * 配合 duration 320ms 就是"在第一个周期内收敛",不会看到反复晃; + * · damping 26 让振荡在 1 个周期内衰减完 —— 给 30+ 会完全没有回弹(发死), + * 给 15 以下会明显晃两下(廉价感)。 + */ +function sheetSpring(): ICurve { + return curves.springCurve(0, 1, 228, 26); +} + +/** + * 给单个效果挂上动画参数。 + * + * ⚠️ 存在的理由:`TransitionEffect.OPACITY` / `.scale()` 这些成员**自带默认 + * 1000ms 动画**。把"每个子效果用什么时长"显式写出来, + * 可以让这条路径的参数在代码里一眼可见 —— 本项目踩过 + * "以为设了时长、实际走了默认 1000ms"的坑。 + */ +function withAnim(effect: TransitionEffect, duration: number, curve: Curve | ICurve): TransitionEffect { + return effect.animation({ duration: duration, curve: curve }); +} + +/** + * 卡片入场:淡入 + 放大 + 自下而上。 + * + * ⚠️ 三者的**顺序不敏感**(combine 是并集),但都必须在最后统一再挂一次 + * `animation` —— 见文件头关于 `combine` 的 SDK 原文。 + */ +function sheetIn(duration: number, curve: Curve | ICurve): TransitionEffect { + const merged: TransitionEffect = TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: SHEET_SCALE_FROM, y: SHEET_SCALE_FROM })) + .combine(TransitionEffect.translate({ y: SHEET_RISE_VP })); + return withAnim(merged, duration, curve); +} + +/** + * 卡片出场:淡出 + 缩到 0.96 + 往下 10vp(与入场反向,路径对称)。 + * + * ⚠️ 出场**不回落到 SHEET_SCALE_FROM**:那样会让卡片"缩没了", + * 关闭时用户已经不想看细节,快速淡出比"表演一次缩小"更舒服。 + */ +function sheetOut(duration: number, curve: Curve | ICurve): TransitionEffect { + const merged: TransitionEffect = TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: 0.96, y: 0.96 })) + .combine(TransitionEffect.translate({ y: 10 })); + return withAnim(merged, duration, curve); +} + +// ============================================================ +// 对外:遮罩 +// ============================================================ /** * 遮罩过渡:只做透明度,不动位置。 * * ⚠️ 遮罩铺满全屏,**不能**用缩放/位移 —— 那会让边缘露出没压暗的底色。 + * ⚠️ 进/出时长不同(240 / 160),所以必须用 `asymmetric`: + * 原来只写一个 `.animation()`,进出同长,关闭时遮罩撤得比卡片慢, + * 观感是"卡片没了、黑底还赖着"。 + * ⚠️ 曲线从 `Curve.Friction` 换成 `EaseOut`:Friction 是"快速减速", + * 适合跟手的拖拽/滚动;纯淡入淡出用 EaseOut 更干净 —— + * 起手即有响应、尾段缓慢收住,正是"压暗"该有的节奏。 */ export function fbMaskTransition(): TransitionEffect { - return TransitionEffect.OPACITY.animation({ duration: MASK_MS, curve: Curve.Friction }); + return TransitionEffect.asymmetric( + withAnim(TransitionEffect.OPACITY, MASK_IN_MS, Curve.EaseOut), + withAnim(TransitionEffect.OPACITY, MASK_OUT_MS, Curve.EaseIn) + ); } +// ============================================================ +// 对外:浮层卡片 +// ============================================================ + /** - * 浮层卡片过渡:淡入 + 从 0.94 放大到 1(消失时反向)。 + * 浮层卡片过渡(**全站统一入口**,98 处引用)。 * - * 🔴 用户说的"点击详情,框变大了"就是这一条: - * 从简要卡换到详情卡是一次**整层替换**(简要卡消失、详情卡出现), - * 两张卡各自做"淡入 + 放大"⇒ 观感上就是"同一个框长大了一号"。 + * 入场:淡入 + 0.90→1 放大 + 从下方 22vp 托起,320ms,弹性收尾。 + * 出场:淡出 + 缩到 0.96 + 往下 10vp,200ms,EaseIn。 * - * ⚠️ 缩放比不要给太小(如 0.5):本工程的浮层卡片是"92% 宽、高度按内容", - * 缩太小会先看到一个小方块弹一下,反而像另一个控件; - * 0.94 是"刚好看出在长大、但不觉得跳"的甜点。 - * ⚠️ 缩放中心默认是组件中心;卡片根节点是"铺满屏幕 + 内容居中", - * 所以视觉上就是**围绕屏幕中心长大**,正是要的效果。 + * 🔴 曲线为什么用弹簧(`sheetSpring()` = `curves.springCurve(0, 1, 228, 26)`) + * 而不是预设曲线: + * "丝滑"的本质是**末段有减速、落定时不硬停**。`FastOutSlowIn` 虽然首尾都慢, + * 但它是固定时长缓动,落定那一下仍然是"停住"; + * 物理弹簧的末段是自然收敛的,主观感受明显更有质感。 + * ⚠️ 参数含义与取值依据见 `sheetSpring()` 的注释(含"为什么不能用 + * `Curve.springMotion`" —— 本工程 SDK 里没有那个 API,会编译失败)。 */ export function fbSheetTransition(): TransitionEffect { - const effect: TransitionEffect = TransitionEffect.OPACITY - .combine(TransitionEffect.scale({ x: 0.94, y: 0.94 })); - // ⚠️ combine 之后必须**再挂一次** animation(见文件头说明) - return effect.animation({ duration: SHEET_MS, curve: Curve.FastOutSlowIn }); + const spring: ICurve = sheetSpring(); + return TransitionEffect.asymmetric( + sheetIn(SHEET_IN_MS, spring), + sheetOut(SHEET_OUT_MS, Curve.EaseIn) + ); } /** * 从小窗换到大窗时的"继续长大"过渡(简要卡 → 详情卡)。 * - * 与 `fbSheetTransition` 的区别只在**起始缩放比**:这里是 0.96(几乎等大), - * 因为上一层的卡片已经在屏幕上了,从 0.94 起会看到"先缩一下再长大"的抖动。 + * 与 `fbSheetTransition` 的区别只在**起始缩放比**更大(0.96,几乎等大): + * 因为上一层的卡片已经在屏幕上了,从 0.90 起会看到"先缩一下再长大"的抖动。 + * ⚠️ 位移也减半(8vp):两张卡都在屏幕中间,位移太大会像"跳了一下"。 + * ⚠️ 弹簧响应略快(0.32):这是"在已有东西上叠一层",比全新入场要更跟手。 */ export function fbSheetGrowTransition(): TransitionEffect { - const effect: TransitionEffect = TransitionEffect.OPACITY - .combine(TransitionEffect.scale({ x: 0.96, y: 0.96 })); - return effect.animation({ duration: SHEET_MS, curve: Curve.FastOutSlowIn }); + const spring: ICurve = sheetSpring(); + const appear: TransitionEffect = withAnim( + TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: 0.96, y: 0.96 })) + .combine(TransitionEffect.translate({ y: 8 })), + SHEET_IN_MS, spring); + const disappear: TransitionEffect = withAnim( + TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: 0.97, y: 0.97 })), + SHEET_OUT_MS, Curve.EaseIn); + return TransitionEffect.asymmetric(appear, disappear); } /** diff --git a/entry/src/main/ets/components/FbEventSheet.ets b/entry/src/main/ets/components/FbEventSheet.ets index 7376ee9..523cba3 100644 --- a/entry/src/main/ets/components/FbEventSheet.ets +++ b/entry/src/main/ets/components/FbEventSheet.ets @@ -370,15 +370,27 @@ export struct FbEventSheet { .margin({ top: 12, bottom: 4 }) if (this.loading) { - Row({ space: 8 }) { - LoadingProgress().width(18).height(18).color(FbColors.faint) - Text('正在读取…') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) + /* 🔴 2026-11-06:加载态要给足高度,别只放一行「正在读取…」。 + 用户反馈"弹框动画不丝滑"。原因同 `FbMovieDetailSheet`: + 卡片高度按内容定,加载态只有一行字 ⇒ 卡片很矮; + 数据回来立刻撑成满屏,**入场动画被高度突变打断**。 + ⇒ 用骨架块占住常规高度,动画期间尺寸基本恒定。 + ⚠️ 固定 vp 而不是百分比:这里渲染在 Scroll 里, + Scroll 高度按内容定,百分比拿不到基准。 */ + Column({ space: 12 }) { + ForEach([0, 1, 2, 3], (i: number) => { + Column() + .width(i === 1 ? '82%' : (i === 2 ? '88%' : (i === 0 ? '44%' : '60%'))) + .height(i === 0 ? 18 : 12) + .borderRadius(6) + .backgroundColor(FbColors.inputBg) + }, (i: number): string => 'evsk_' + i.toString()) } .width('100%') + .height(300) .padding({ top: 20, bottom: 20 }) .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Start) } else if (this.event === null) { // 正常情况下不会走到这里(`openEvent` 一定会垫一份壳); // 只有"打开浮层时状态被清掉"这种瞬时态才可能空着 —— diff --git a/entry/src/main/ets/components/FbMovieDetailSheet.ets b/entry/src/main/ets/components/FbMovieDetailSheet.ets index 8133586..d17b491 100644 --- a/entry/src/main/ets/components/FbMovieDetailSheet.ets +++ b/entry/src/main/ets/components/FbMovieDetailSheet.ets @@ -294,14 +294,43 @@ export struct FbMovieDetailSheet { .padding({ top: 40, bottom: 40 }) .justifyContent(FlexAlign.Center) } else if (this.loading || this.detail === null) { - Row() { - Text('正在加载详情…') - .fontSize(FbMetric.fontSmall) - .fontColor(FbColors.faint) + /* 🔴 2026-11-06:**加载态要占住卡片的常规高度**,不能只放一行字。 + 用户说「观影基本上没动画」。核实后原因是: + 卡片高度是"按内容"的(`maxHeight: 72%` 只是上限), + 加载态只有一行「正在加载详情…」⇒ 卡片约 100px 高; + 详情一回来,内容把卡片瞬间撑到 72% 屏高。 + 入场动画(320ms 淡入+放大+上浮)只播了前 1/3 就被这次 + **高度突变**打断 —— 用户看到的就是"闪一下就变满",没有动画感。 + ⇒ 加载态放**固定 460vp** 的骨架(约等于这条卡详情态的常见高度), + 让动画期间尺寸基本恒定;内容回来只是把里面换掉,不再整卡重排。 + ⚠️ 为什么用固定 vp 而不是 `height('100%')`: + 这个 builder 渲染在 `Scroll` 里,Scroll 的高度是"按内容"的, + 百分比高度拿不到确定基准 ⇒ 会退化成一像素或直接不渲染。 + 固定值是这个结构下唯一可靠的选择(与本文件其它地方的取舍一致)。 + ⚠️ 460 的依据:详情态常见内容(海报 140 + 片名/时间/评分 + 影院 + + 简介 3~5 行 + 照片一行 + 评价区标题)大约 430~500vp, + 取中间值,两侧都不会有明显跳变。 */ + Column({ space: 12 }) { + /* 骨架块:比一行灰字更像"内容正在来",也把高度撑住 */ + Column() + .width(96) + .height(128) + .borderRadius(FbMetric.radiusS) + .backgroundColor(FbColors.inputBg) + ForEach([0, 1, 2, 3], (i: number) => { + Column() + .width(i === 0 ? '40%' : (i === 1 ? '78%' : (i === 2 ? '86%' : '58%'))) + .height(i === 0 ? 16 : 11) + .borderRadius(6) + .backgroundColor(FbColors.inputBg) + .margin({ top: i === 0 ? 6 : 0 }) + }, (i: number): string => 'sk_' + i.toString()) } .width('100%') - .padding({ top: 40, bottom: 40 }) + .height(460) + .padding({ top: 24, bottom: 24 }) .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) } else { this.body(this.detail) }