diff --git a/env.d.ts b/env.d.ts index 27d15a17..32e8f68c 100644 --- a/env.d.ts +++ b/env.d.ts @@ -6,6 +6,8 @@ declare module 'vue-router' { subject?: string keepAlive?: boolean keepAliveKey?: string + /** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */ + pagePresentationHandoff?: 'staged' layoutWrapperClasses?: string navActiveLink?: RouteLocationRaw requiresAuth?: boolean diff --git a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts index 076f5153..bf2bc0ab 100644 --- a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts +++ b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts @@ -474,15 +474,18 @@ describe('glass optical surface discovery', () => { const bounds = { height: 240, width: 360, x: 80, y: 100 } appendOpticalSurface('app-hover-lift-card', bounds) const active = ref(true) + const acknowledgeGeometryReady = vi.fn() + const epoch = ref(1) const opacity = ref(1) const revision = ref(0) + const appearance = ref<'clear' | 'frosted'>('clear') const scope = effectScope() const renderer = scope.run(() => useGlassOpticalRenderer({ active: ref(true), - appearance: ref('clear'), + appearance, canvas: ref(canvas), - pageMotion: { active, opacity, revision }, + pageMotion: { acknowledgeGeometryReady, active, epoch, opacity, revision }, quality: ref('balanced'), routeKey: ref('/dashboard'), surfaceSpace: 'scroll', @@ -519,6 +522,12 @@ describe('glass optical surface discovery', () => { expect(uniforms.uRects.value[0].y).not.toBe(initialY) expect(uniforms.uSurfaceWeights.value[0]).toBeCloseTo(0.42) + appearance.value = 'frosted' + opacity.value = 0.18 + revision.value += 1 + const frostedScene = render.mock.calls.at(-1)?.[0] as unknown as typeof initialScene + expect(frostedScene.children[0].material.uniforms.uSurfaceWeights.value[0]).toBe(1) + const observer = ResizeObserverMock.instances.find(instance => instance.targets.has(root)) expect(observer).toBeDefined() setSize.mockClear() @@ -532,6 +541,67 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('acknowledges the current page motion only after route surfaces remain stable', async () => { + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + const canvas = document.createElement('canvas') + const root = document.createElement('div') + root.append(canvas) + document.body.append(root) + appendOpticalSurface('app-hover-lift-card', { height: 240, width: 360, x: 80, y: 100 }) + const acknowledgeGeometryReady = vi.fn() + const active = ref(true) + const epoch = ref(7) + const routeKey = ref('/dashboard') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + pageMotion: { + acknowledgeGeometryReady, + active, + epoch, + opacity: ref(0), + revision: ref(1), + }, + quality: ref('balanced'), + routeKey, + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + routeKey.value = '/discover' + await nextTick() + epoch.value = 8 + routeKey.value = '/dashboard' + await nextTick() + await nextTick() + + for (let pass = 0; pass < 8 && !acknowledgeGeometryReady.mock.calls.length; pass += 1) { + const queued = [...callbacks.entries()] + callbacks.clear() + queued.forEach(([, callback]) => callback(1000 + pass * 16)) + await nextTick() + } + + expect(acknowledgeGeometryReady).toHaveBeenCalledOnce() + expect(acknowledgeGeometryReady).toHaveBeenCalledWith(8, expect.any(Number)) + scope.stop() + }) + it('recovers after consecutive WebGL context loss cycles', async () => { const three = await import('three') const canvas = document.createElement('canvas') diff --git a/src/composables/__tests__/usePagePresentationMotion.spec.ts b/src/composables/__tests__/usePagePresentationMotion.spec.ts index 4edcab4e..3f68445b 100644 --- a/src/composables/__tests__/usePagePresentationMotion.spec.ts +++ b/src/composables/__tests__/usePagePresentationMotion.spec.ts @@ -1,5 +1,6 @@ import { getPagePresentationMotionProgress, + PAGE_PRESENTATION_FROSTED_START_TRANSLATE_Y, PAGE_PRESENTATION_MOTION_DURATION_MS, PAGE_PRESENTATION_MOTION_START_OPACITY, PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y, @@ -16,6 +17,8 @@ beforeEach(() => { callbacks = new Map() frameId = 0 document.documentElement.dataset.theme = 'glass' + document.documentElement.dataset.glassAppearance = 'clear' + document.documentElement.dataset.glassQuality = 'high' delete document.documentElement.dataset.launchLoading vi.spyOn(performance, 'now').mockReturnValue(1000) vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { @@ -33,6 +36,8 @@ afterEach(() => { motion.cancel() document.getElementById('loading-bg')?.remove() delete document.documentElement.dataset.theme + delete document.documentElement.dataset.glassAppearance + delete document.documentElement.dataset.glassQuality delete document.documentElement.dataset.launchLoading delete document.documentElement.dataset.pagePresentationMotion document.documentElement.style.removeProperty('--mp-page-motion-opacity') @@ -40,6 +45,25 @@ afterEach(() => { }) describe('page presentation motion', () => { + it('delegates standard clear glass to the ordinary compositor animation', () => { + document.documentElement.dataset.glassQuality = 'css' + + expect(motion.start('/dashboard', document.createElement('div'))).toBe(false) + expect(motion.active.value).toBe(false) + expect(callbacks.size).toBe(0) + }) + + it('starts standard frosted motion without waiting for a renderer geometry acknowledgement', () => { + document.documentElement.dataset.glassAppearance = 'frosted' + document.documentElement.dataset.glassQuality = 'css' + + expect(motion.start('/dashboard', document.createElement('div'))).toBe(true) + expect(motion.active.value).toBe(true) + expect(motion.opacity.value).toBe(1) + expect(motion.translateY.value).toBe(PAGE_PRESENTATION_FROSTED_START_TRANSLATE_Y) + expect(callbacks.size).toBe(1) + }) + it('does not add a second reveal gate behind the initial launch screen', () => { document.documentElement.dataset.launchLoading = 'true' const launchScreen = document.createElement('div') @@ -95,6 +119,58 @@ describe('page presentation motion', () => { routeRoot.remove() }) + it('reveals clear glass when the renderer confirms current surface geometry', () => { + const routeRoot = document.createElement('div') + Object.defineProperties(routeRoot, { + offsetHeight: { configurable: true, get: () => 2096 }, + offsetWidth: { configurable: true, get: () => 1200 }, + scrollHeight: { configurable: true, get: () => 2096 }, + scrollWidth: { configurable: true, get: () => 1200 }, + }) + document.body.append(routeRoot) + + expect(motion.start('/dashboard', routeRoot)).toBe(true) + const motionEpoch = motion.epoch.value + expect(motion.opacity.value).toBe(0) + expect(motion.reader.acknowledgeGeometryReady(motionEpoch - 1, 1040)).toBe(false) + expect(motion.opacity.value).toBe(0) + + expect(motion.reader.acknowledgeGeometryReady(motionEpoch, 1040)).toBe(true) + expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY) + expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y) + expect(callbacks.size).toBe(1) + + routeRoot.remove() + }) + + it('keeps frosted material fully composed when the renderer releases its geometry hold', () => { + document.documentElement.dataset.glassAppearance = 'frosted' + const routeRoot = document.createElement('div') + Object.defineProperties(routeRoot, { + offsetHeight: { configurable: true, get: () => 1520 }, + offsetWidth: { configurable: true, get: () => 1200 }, + scrollHeight: { configurable: true, get: () => 1520 }, + scrollWidth: { configurable: true, get: () => 1200 }, + }) + document.body.append(routeRoot) + + expect(motion.start('/dashboard', routeRoot)).toBe(true) + expect(motion.active.value).toBe(true) + expect(motion.opacity.value).toBe(1) + expect(motion.translateY.value).toBe(PAGE_PRESENTATION_FROSTED_START_TRANSLATE_Y) + expect(document.documentElement.style.getPropertyValue('--mp-page-motion-opacity')).toBe('1') + expect(document.documentElement.style.getPropertyValue('--mp-page-motion-translate-y')).toBe('8px') + expect(motion.reader.acknowledgeGeometryReady(motion.epoch.value, 1040)).toBe(true) + + ;[1016, 1140, 1260, 1440].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp)) + expect(motion.active.value).toBe(false) + expect(motion.opacity.value).toBe(1) + expect(motion.translateY.value).toBe(0) + expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined() + + routeRoot.remove() + }) + it('uses one eased timeline for the initial, intermediate, and settled states', () => { const initialRevision = motion.revision.value diff --git a/src/composables/__tests__/useRouteEnterMotion.spec.ts b/src/composables/__tests__/useRouteEnterMotion.spec.ts new file mode 100644 index 00000000..d88e0f6b --- /dev/null +++ b/src/composables/__tests__/useRouteEnterMotion.spec.ts @@ -0,0 +1,161 @@ +import { useRouteEnterMotion } from '@/composables/useRouteEnterMotion' +import { effectScope, type EffectScope } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +interface AnimationStub { + animation: Animation + cancel: ReturnType + finish: () => void + pause: ReturnType + play: ReturnType +} + +let callbacks: Map +let frameId: number +let scope: EffectScope + +function createAnimationStub(): AnimationStub { + let finish!: () => void + const finished = new Promise(resolve => { + finish = resolve + }) + const cancel = vi.fn() + const pause = vi.fn() + const play = vi.fn() + const animation = { + cancel, + currentTime: null, + finished, + pause, + play, + } as unknown as Animation + + return { animation, cancel, finish, pause, play } +} + +function createMotion() { + let motion!: ReturnType + scope.run(() => { + motion = useRouteEnterMotion() + }) + + return motion +} + +function runNextFrame(timestamp = 16) { + const [id, callback] = callbacks.entries().next().value! + callbacks.delete(id) + callback(timestamp) +} + +beforeEach(() => { + callbacks = new Map() + frameId = 0 + scope = effectScope() + delete document.documentElement.dataset.launchLoading + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) +}) + +afterEach(() => { + scope.stop() + document.getElementById('loading-bg')?.remove() + delete document.documentElement.dataset.launchLoading +}) + +describe('route enter motion', () => { + it('commits a paused starting frame before playing the default route animation', () => { + const root = document.createElement('div') + const stub = createAnimationStub() + root.animate = vi.fn(() => stub.animation) + const motion = createMotion() + + expect(motion.start(root)).toBe(true) + expect(stub.pause).toHaveBeenCalledOnce() + expect(stub.animation.currentTime).toBe(0) + expect(stub.play).not.toHaveBeenCalled() + expect(motion.phase.value).toBe('armed') + + runNextFrame(160) + expect(stub.play).toHaveBeenCalledOnce() + expect(motion.phase.value).toBe('running') + }) + + it('commits one paint boundary before a staged handoff', () => { + const root = document.createElement('div') + const stub = createAnimationStub() + root.animate = vi.fn(() => stub.animation) + const motion = createMotion() + + motion.start(root, { stagedHandoff: true }) + runNextFrame() + expect(stub.play).toHaveBeenCalledOnce() + }) + + it('cancels the previous animation and pending frame on rapid navigation', () => { + const root = document.createElement('div') + const first = createAnimationStub() + const second = createAnimationStub() + root.animate = vi.fn().mockReturnValueOnce(first.animation).mockReturnValueOnce(second.animation) + const motion = createMotion() + + motion.start(root) + motion.start(root) + + expect(first.cancel).toHaveBeenCalledOnce() + expect(callbacks.size).toBe(1) + runNextFrame() + expect(first.play).not.toHaveBeenCalled() + expect(second.play).toHaveBeenCalledOnce() + }) + + it('cleans up the finished animation without a fixed timer', async () => { + const root = document.createElement('div') + const stub = createAnimationStub() + root.animate = vi.fn(() => stub.animation) + const motion = createMotion() + + motion.start(root) + runNextFrame() + stub.finish() + await stub.animation.finished + await Promise.resolve() + + expect(stub.cancel).toHaveBeenCalledOnce() + expect(motion.phase.value).toBe('idle') + }) + + it('skips route animation while the launch screen owns presentation', () => { + document.documentElement.dataset.launchLoading = 'true' + const launchScreen = document.createElement('div') + launchScreen.id = 'loading-bg' + document.body.append(launchScreen) + const root = document.createElement('div') + root.animate = vi.fn() + const motion = createMotion() + + expect(motion.start(root)).toBe(false) + expect(root.animate).not.toHaveBeenCalled() + expect(callbacks.size).toBe(0) + }) + + it('skips route animation when reduced motion is requested', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + ...window.matchMedia(''), + matches: true, + }) + const root = document.createElement('div') + root.animate = vi.fn() + const motion = createMotion() + + expect(motion.start(root)).toBe(false) + expect(root.animate).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/useGlassOpticalRenderer.ts b/src/composables/useGlassOpticalRenderer.ts index 1df3d541..5771c1ad 100644 --- a/src/composables/useGlassOpticalRenderer.ts +++ b/src/composables/useGlassOpticalRenderer.ts @@ -1393,6 +1393,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) let surfaceTransformTrackingDeadline = 0 const transformingSurfaces = new Set() let pagePresentationGeometryReady = true + let pagePresentationMotionEpoch: number | null = null let wakeDirection = { x: 0, y: -1 } let contextRecoveryPending = false let resumePromise: Promise | null = null @@ -1889,7 +1890,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) ? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS) : { incoming: 1, outgoing: 0 } const pageMotionOpacity = - presentationSpace === 'scroll' ? Math.min(1, Math.max(0, toValue(options.pageMotion?.opacity ?? 1))) : 1 + presentationSpace === 'scroll' && toValue(options.appearance) !== 'frosted' + ? Math.min(1, Math.max(0, toValue(options.pageMotion?.opacity ?? 1))) + : 1 const pagePresentationWeight = pagePresentationGeometryReady ? pageMotionOpacity : 0 for (let index = 0; index < 8; index += 1) { @@ -2114,7 +2117,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } /** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */ - function scheduleSurfaceStabilityUpdate() { + function scheduleSurfaceStabilityUpdate(motionEpoch?: number) { + if (motionEpoch !== undefined) pagePresentationMotionEpoch = motionEpoch if (queueScrollGeometryRefresh(true)) return surfaceStabilityPass = 0 surfaceStableFrameCount = 0 @@ -2147,6 +2151,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) pagePresentationGeometryReady = true writeSurfaceUniforms(timestamp) renderFrame(timestamp, false) + const acknowledgedEpoch = pagePresentationMotionEpoch + pagePresentationMotionEpoch = null + if (acknowledgedEpoch !== null) { + options.pageMotion?.acknowledgeGeometryReady(acknowledgedEpoch, timestamp) + } } } @@ -3939,6 +3948,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) { flush: 'sync' }, ) + watch( + () => [toValue(options.pageMotion?.active ?? false), toValue(options.pageMotion?.epoch ?? 0)] as const, + ([motionActive, motionEpoch]) => { + if (!resources || presentationSpace !== 'scroll' || !motionActive) return + + // motion epoch 是页面事务的唯一身份;同步重置稳定采样,避免旧路由的尾帧释放新事务。 + pagePresentationGeometryReady = false + scheduleSurfaceStabilityUpdate(motionEpoch) + }, + { flush: 'sync' }, + ) + watch( () => toValue(options.routeKey), async (routeKey, previousRouteKey) => { @@ -3951,6 +3972,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) if (previousKey !== nextKey) invalidatePreparedWallpaper() if (resources && presentationSpace === 'scroll' && options.pageMotion) { pagePresentationGeometryReady = false + pagePresentationMotionEpoch = toValue(options.pageMotion.epoch) const timestamp = performance.now() updateSurfaceUniforms(timestamp, false) renderFrame(timestamp, false) @@ -3966,8 +3988,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) return } } - if (presentationSpace === 'scroll' && options.pageMotion) scheduleSurfaceStabilityUpdate() - else scheduleSurfaceUpdate() + if (presentationSpace === 'scroll' && options.pageMotion) { + scheduleSurfaceStabilityUpdate(toValue(options.pageMotion.epoch)) + } else scheduleSurfaceUpdate() }, ) diff --git a/src/composables/usePagePresentationMotion.ts b/src/composables/usePagePresentationMotion.ts index 33695940..dcaa862b 100644 --- a/src/composables/usePagePresentationMotion.ts +++ b/src/composables/usePagePresentationMotion.ts @@ -3,14 +3,19 @@ import { readonly, ref, type Ref } from 'vue' export const PAGE_PRESENTATION_MOTION_DURATION_MS = 180 export const PAGE_PRESENTATION_MOTION_START_OPACITY = 0.88 export const PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y = 4 +export const PAGE_PRESENTATION_FROSTED_START_TRANSLATE_Y = 8 export const PAGE_PRESENTATION_LAYOUT_STABLE_MS = 120 export const PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS = 480 /** renderer 只读取同一帧已经提交到 DOM 的页面呈现状态。 */ export interface PagePresentationMotionReader { + /** renderer 确认当前事务的 surface 几何已稳定后,允许页面开始 reveal。 */ + acknowledgeGeometryReady: (motionEpoch: number, timestamp?: number) => boolean /** 页面是否处于共享呈现事务中。 */ active: Readonly> - /** 当前页面材质与 DOM 共同使用的透明度。 */ + /** 当前呈现事务版本;旧 surface 采样不得完成新事务。 */ + epoch: Readonly> + /** 页面内容的呈现透明度;renderer 按材质合成约束决定是否使用。 */ opacity: Readonly> /** 每次 DOM motion 样式提交后递增,renderer 据此在同一帧刷新表面。 */ revision: Readonly> @@ -27,7 +32,9 @@ let animationFrame: number | null = null let layoutHoldStartedAt = 0 let layoutStableSince = 0 let layoutSignature = '' +let motionStartTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y let startedAt = 0 +let preserveFrostedMaterial = false function sampleBezier(time: number, start: number, end: number) { const inverse = 1 - time @@ -64,9 +71,10 @@ function clearDocumentMotionState() { /** 先提交 DOM 样式,再发布 revision,保证 renderer 读取到同一帧的真实矩形。 */ function applyMotionFrame(nextProgress: number) { const root = document.documentElement - const nextOpacity = - PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress - const nextTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y * (1 - nextProgress) + const nextOpacity = preserveFrostedMaterial + ? 1 + : PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress + const nextTranslateY = motionStartTranslateY * (1 - nextProgress) root.dataset.pagePresentationMotion = 'active' root.style.setProperty('--mp-page-motion-opacity', nextOpacity.toFixed(4)) @@ -82,11 +90,11 @@ function applyLayoutHoldFrame() { const root = document.documentElement root.dataset.pagePresentationMotion = 'active' - root.style.setProperty('--mp-page-motion-opacity', '0') - root.style.setProperty('--mp-page-motion-translate-y', `${PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y}px`) - opacity.value = 0 + root.style.setProperty('--mp-page-motion-opacity', preserveFrostedMaterial ? '1' : '0') + root.style.setProperty('--mp-page-motion-translate-y', `${motionStartTranslateY}px`) + opacity.value = preserveFrostedMaterial ? 1 : 0 progress.value = 0 - translateY.value = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y + translateY.value = motionStartTranslateY revision.value += 1 } @@ -102,6 +110,17 @@ function beginReveal(timestamp: number, motionEpoch: number) { animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch)) } +/** GPU surface 比整页高度更早稳定时,直接结束布局等待。 */ +function acknowledgeGeometryReady(motionEpoch: number, timestamp = performance.now()) { + if (!active.value || epoch.value !== motionEpoch) return false + + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame) + animationFrame = null + beginReveal(timestamp, motionEpoch) + + return true +} + /** 页面根持续稳定后才开始 reveal;上限避免持续布局页面永久不可见。 */ function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElement) { if (!active.value || epoch.value !== motionEpoch) return @@ -161,7 +180,7 @@ function renderFrame(timestamp: number, motionEpoch: number) { } /** - * 玻璃主题由共享控制器接管页面入场;其他主题继续使用既有 CSS keyframe。 + * 需要 renderer 同步或保持磨砂密度的玻璃页面由共享控制器接管;其他页面交给普通 WAAPI。 * 返回 true 表示本次路由变化已经处理,包括 reduced-motion 的即时提交。 */ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) { @@ -175,6 +194,17 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) { epoch.value += 1 const motionEpoch = epoch.value routeKey.value = nextRouteKey + preserveFrostedMaterial = document.documentElement.dataset.glassAppearance === 'frosted' + const usesCssQuality = document.documentElement.dataset.glassQuality === 'css' + if (usesCssQuality && !preserveFrostedMaterial) { + settleMotion() + revision.value += 1 + return false + } + + motionStartTranslateY = preserveFrostedMaterial + ? PAGE_PRESENTATION_FROSTED_START_TRANSLATE_Y + : PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y // 启动屏已完整遮罩页面;在其背后再等待布局稳定会把一次启动拆成两次可见揭示。 if (document.documentElement.dataset.launchLoading === 'true' && document.getElementById('loading-bg')) { @@ -191,7 +221,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) { active.value = true const timestamp = performance.now() - if (layoutRoot) { + if (layoutRoot && !usesCssQuality) { layoutHoldStartedAt = timestamp layoutStableSince = timestamp layoutSignature = getLayoutSignature(layoutRoot) @@ -207,7 +237,9 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) { } const reader: PagePresentationMotionReader = { + acknowledgeGeometryReady, active: readonly(active), + epoch: readonly(epoch), opacity: readonly(opacity), revision: readonly(revision), } diff --git a/src/composables/useRouteEnterMotion.ts b/src/composables/useRouteEnterMotion.ts new file mode 100644 index 00000000..924801be --- /dev/null +++ b/src/composables/useRouteEnterMotion.ts @@ -0,0 +1,116 @@ +import { onScopeDispose, readonly, ref } from 'vue' + +export const ROUTE_ENTER_MOTION_DURATION_MS = 180 +export const ROUTE_ENTER_MOTION_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)' +export const ROUTE_ENTER_STAGED_PAINT_BOUNDARIES = 1 + +export type RouteEnterMotionPhase = 'idle' | 'armed' | 'running' + +export interface RouteEnterMotionOptions { + /** 重页面离场时多保留一个绘制边界,确保目标页起始态已交给 compositor。 */ + stagedHandoff?: boolean +} + +function shouldSkipRouteEnterMotion() { + const launchScreenActive = + document.documentElement.dataset.launchLoading === 'true' && Boolean(document.getElementById('loading-bg')) + + return ( + launchScreenActive || document.hidden || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true + ) +} + +/** + * 管理普通页面的入场动画时钟。动画在起始态暂停,经过所需绘制边界后才开始计时, + * 避免主线程长帧让浏览器跳过动画前段。 + */ +export function useRouteEnterMotion() { + const phase = ref('idle') + let activeAnimation: Animation | null = null + let animationFrame: number | null = null + let epoch = 0 + + function cancel() { + epoch += 1 + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame) + animationFrame = null + activeAnimation?.cancel() + activeAnimation = null + phase.value = 'idle' + } + + function playAfterPaints(animation: Animation, remainingPaints: number, motionEpoch: number) { + if (motionEpoch !== epoch || animation !== activeAnimation) return + + if (remainingPaints <= 0) { + phase.value = 'running' + animation.play() + return + } + + animationFrame = window.requestAnimationFrame(() => { + animationFrame = null + playAfterPaints(animation, remainingPaints - 1, motionEpoch) + }) + } + + function start(root: HTMLElement | null | undefined, options: RouteEnterMotionOptions = {}) { + cancel() + if (!root || shouldSkipRouteEnterMotion() || typeof root.animate !== 'function') return false + + epoch += 1 + const motionEpoch = epoch + const animation = root.animate( + [ + { + opacity: 0, + transform: 'translate3d(0, 0.5rem, 0) scale(0.992)', + }, + { + opacity: 1, + transform: 'translate3d(0, 0, 0) scale(1)', + }, + ], + { + duration: ROUTE_ENTER_MOTION_DURATION_MS, + easing: ROUTE_ENTER_MOTION_EASING, + fill: 'both', + }, + ) + + activeAnimation = animation + animation.pause() + animation.currentTime = 0 + phase.value = 'armed' + + void animation.finished + .then(() => { + if (motionEpoch !== epoch || animation !== activeAnimation) return + activeAnimation = null + phase.value = 'idle' + animation.cancel() + }) + .catch(() => { + // cancel() 会拒绝 finished;epoch 已负责丢弃过期事务。 + }) + + playAfterPaints(animation, options.stagedHandoff ? ROUTE_ENTER_STAGED_PAINT_BOUNDARIES : 1, motionEpoch) + return true + } + + function handleVisibilityChange() { + if (document.hidden) cancel() + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + onScopeDispose(() => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + cancel() + }) + + return { + cancel, + phase: readonly(phase), + start, + } +} diff --git a/src/layouts/default.vue b/src/layouts/default.vue index 86b195fa..41e2be69 100644 --- a/src/layouts/default.vue +++ b/src/layouts/default.vue @@ -1,9 +1,11 @@ @@ -61,7 +51,7 @@ onBeforeUnmount(() => {