修复资源画本动画期间布局变化导致的跳变

上一版在布局变化时重启整段动画,导致此前修好的遮挡顺序与加载态全部复现,已回滚该做法
控制器新增 retarget:临时解除场景隐藏后重新测量真实卡片,只把运行中动画的结束帧改成映射到新几何;克隆几何、动画进度与叠加层都不重建
布局变化 effect 在动画期间调用 retarget,空闲时才 settle
补充 resourceBookController 用例:重定向不新建动画,结束帧映射到新目标
This commit is contained in:
2026-09-09 17:24:00 +08:00
parent ed50ad122f
commit 91f335468a
3 changed files with 91 additions and 27 deletions
@@ -2585,24 +2585,13 @@ export default function ProjectDevelopmentView({
previous.resources !== current.resources ||
previous.sortMode !== current.sortMode)
) {
// 卡片尺寸要等预览图尺寸到位才算准,通常正好落在动画末尾;直接 settle
// 会让真实场景瞬间接管,看起来就是结束瞬间跳一下改为从当前叠加层几何
// 重定向,动画平滑续到新的目标位置
// 动画期间布局(卡片尺寸/位置)会随预览图尺寸到位而更新。此时直接 settle
// 会让真实场景瞬间接管,看起来就是结束瞬间跳一下改为只更新运行中动画的
// 终点,不重建叠加层、不打断飞行
const manager = resourceBookManagerRef.current;
const controller = resourceBookTransitionControllerRef.current;
if (resourceBookState.phase !== 'idle' && manager) {
const retargetToken = controller.begin(manager);
controller.play(
manager,
retargetToken,
() => {
dispatchResourceBook({
type: 'finish-transition',
token: resourceBookState.token,
});
},
resourceBookState.phase === 'returning-main' ? 'target' : 'source',
);
controller.retarget(manager);
} else {
controller.settle();
}
@@ -156,13 +156,18 @@ function clonePresentation(
return clone;
}
function capture(root: HTMLElement): Map<string, Snapshot> {
function capture(
root: HTMLElement,
options: { ignoreOverlay?: boolean; ignoreVisibility?: boolean } = {},
): Map<string, Snapshot> {
const result = new Map<string, Snapshot>();
const bounds = root.getBoundingClientRect();
const layer = root.querySelector<HTMLElement>(
'.game-resource-book-transition-layer',
);
const moving = layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
const moving = options.ignoreOverlay
? null
: layer?.querySelectorAll<HTMLElement>('[data-motion-snapshot]');
const elements = moving?.length
? Array.from(moving)
: Array.from(
@@ -199,7 +204,7 @@ function capture(root: HTMLElement): Map<string, Snapshot> {
if (style.visibility === 'hidden' || style.display === 'none')
visible = false;
}
if (!visible || opacity <= 0) continue;
if (!options.ignoreVisibility && (!visible || opacity <= 0)) continue;
const key =
element.dataset.motionSnapshot ??
(element.dataset.resourceCardId
@@ -230,6 +235,10 @@ export function createResourceBookTransitionController() {
let token = 0;
let source = new Map<string, Snapshot>();
let animations: Animation[] = [];
let motion = new Map<
string,
{ animation: Animation; rect: Snapshot['rect']; start: Keyframe }
>();
let layer: HTMLElement | null = null;
let root: HTMLElement | null = null;
let completion: (() => void) | null = null;
@@ -239,6 +248,7 @@ export function createResourceBookTransitionController() {
const clear = () => {
animations.forEach((animation) => animation.cancel());
animations = [];
motion = new Map();
layer?.replaceChildren();
root?.removeAttribute('data-book-motion');
observer?.disconnect();
@@ -337,15 +347,16 @@ export function createResourceBookTransitionController() {
});
wrapper.append(item.content);
layer.append(wrapper);
const startFrame: Keyframe = {
transform: resourceBookFlipTransform(
from?.rect ?? item.rect,
item.rect,
),
opacity: from?.opacity ?? 0,
};
const animation = wrapper.animate(
[
{
transform: resourceBookFlipTransform(
from?.rect ?? item.rect,
item.rect,
),
opacity: from?.opacity ?? 0,
},
startFrame,
{
transform: 'translate(0px, 0px) scale(1, 1)',
opacity: to?.opacity ?? 0,
@@ -358,6 +369,11 @@ export function createResourceBookTransitionController() {
},
);
animations.push(animation);
motion.set(item.key, {
animation,
rect: item.rect,
start: startFrame,
});
// Attach rejection handling immediately in case a later item fails
// before the aggregate completion handler has been installed.
void animation.finished.catch(() => undefined);
@@ -387,6 +403,34 @@ export function createResourceBookTransitionController() {
observer.observe(manager);
}
},
/**
* 动画期间布局(卡片尺寸/位置)变化时不要重启动画,只更新运行中动画的终点:
* 临时解除场景隐藏后重新测量真实卡片,再把每条动画的结束帧改成映射到新目标。
* 克隆自身的几何保持不变,因此不需要重建叠加层,也不会打断正在进行的飞行。
*/
retarget(manager: HTMLElement) {
if (motion.size === 0) return;
const hadMotion = manager.hasAttribute('data-book-motion');
manager.removeAttribute('data-book-motion');
const next = capture(manager, {
ignoreOverlay: true,
ignoreVisibility: true,
});
if (hadMotion) manager.setAttribute('data-book-motion', 'running');
if (next.size === 0) return;
for (const [key, entry] of motion) {
const target = next.get(key);
if (!target) continue;
const effect = entry.animation.effect as KeyframeEffect | null;
effect?.setKeyframes([
entry.start,
{
transform: resourceBookFlipTransform(target.rect, entry.rect),
opacity: target.opacity,
},
]);
}
},
dispose: invalidate,
};
}
@@ -35,6 +35,7 @@ function fixture() {
finish(): void;
reject(): void;
cancel: ReturnType<typeof vi.fn>;
setKeyframes: ReturnType<typeof vi.fn>;
frames: Keyframe[];
}[] = [];
vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => {
@@ -45,8 +46,15 @@ function fixture() {
reject = () => fail(new Error('canceled'));
});
const cancel = vi.fn(reject);
flights.push({ finish, reject, cancel, frames: frames as Keyframe[] });
return { finished, cancel } as unknown as Animation;
const setKeyframes = vi.fn();
flights.push({
finish,
reject,
cancel,
setKeyframes,
frames: frames as Keyframe[],
});
return { finished, cancel, effect: { setKeyframes } } as unknown as Animation;
});
return { root, card, measure, flights };
}
@@ -290,6 +298,29 @@ describe('resource book FLIP controller', () => {
controller.dispose();
});
it('retargets a running animation to new geometry without restarting it', () => {
const { root, measure, flights } = setup();
const controller = createResourceBookTransitionController();
const token = controller.begin(root);
measure.mockReturnValue(rect(150, 100, 200, 160));
controller.play(root, token, () => undefined);
expect(flights).toHaveLength(1);
measure.mockReturnValue(rect(300, 200, 100, 80));
controller.retarget(root);
expect(flights).toHaveLength(1);
expect(flights[0]?.setKeyframes).toHaveBeenCalledTimes(1);
const frames = flights[0]?.setKeyframes.mock.calls[0]?.[0] as Keyframe[];
expect(frames[1]?.transform).toBe(
resourceBookFlipTransform(
rect(300, 200, 100, 80),
rect(150, 100, 200, 160),
),
);
controller.dispose();
});
it.each(['reduced', 'zero', 'unsupported'])(
'settles without a timer for %s motion',
(reason) => {