fix(glass): preserve material during route transitions (#611)

This commit is contained in:
InfinityPacer
2026-07-31 14:35:08 +08:00
committed by GitHub
parent 246b8ff54b
commit 41a36e2279
12 changed files with 532 additions and 75 deletions

2
env.d.ts vendored
View File

@@ -6,6 +6,8 @@ declare module 'vue-router' {
subject?: string
keepAlive?: boolean
keepAliveKey?: string
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
pagePresentationHandoff?: 'staged'
layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw
requiresAuth?: boolean

View File

@@ -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<number, FrameRequestCallback>()
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')

View File

@@ -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

View File

@@ -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<typeof vi.fn>
finish: () => void
pause: ReturnType<typeof vi.fn>
play: ReturnType<typeof vi.fn>
}
let callbacks: Map<number, FrameRequestCallback>
let frameId: number
let scope: EffectScope
function createAnimationStub(): AnimationStub {
let finish!: () => void
const finished = new Promise<void>(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<typeof useRouteEnterMotion>
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()
})
})

View File

@@ -1393,6 +1393,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let surfaceTransformTrackingDeadline = 0
const transformingSurfaces = new Set<HTMLElement>()
let pagePresentationGeometryReady = true
let pagePresentationMotionEpoch: number | null = null
let wakeDirection = { x: 0, y: -1 }
let contextRecoveryPending = false
let resumePromise: Promise<void> | 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()
},
)

View File

@@ -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<Ref<boolean>>
/** 当前页面材质与 DOM 共同使用的透明度。 */
/** 当前呈现事务版本;旧 surface 采样不得完成新事务。 */
epoch: Readonly<Ref<number>>
/** 页面内容的呈现透明度renderer 按材质合成约束决定是否使用。 */
opacity: Readonly<Ref<number>>
/** 每次 DOM motion 样式提交后递增renderer 据此在同一帧刷新表面。 */
revision: Readonly<Ref<number>>
@@ -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),
}

View File

@@ -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<RouteEnterMotionPhase>('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() 会拒绝 finishedepoch 已负责丢弃过期事务。
})
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,
}
}

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import DefaultLayout from './default/components/DefaultLayout.vue'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import { useRouteEnterMotion } from '@/composables/useRouteEnterMotion'
const route = useRoute()
const pagePresentationMotion = usePagePresentationMotion()
const routeEnterMotion = useRouteEnterMotion()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => {
@@ -17,43 +19,31 @@ const routeCacheKey = computed(() => {
// 页面过渡按实际页面身份触发keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
const isPageEntering = ref(false)
const routePresentationState = computed(() => ({
handoff: route.meta.pagePresentationHandoff,
key: routeTransitionKey.value,
}))
const pageRouteRef = ref<HTMLElement | null>(null)
let pageMotionTimer: number | null = null
let pageMotionFrame: number | null = null
// 使用稳定容器触发轻量入场动画,避免重建 keep-alive 导致页面缓存失效
function playPageEnterMotion() {
if (pageMotionTimer) {
window.clearTimeout(pageMotionTimer)
pageMotionTimer = null
}
// 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画
function playPageEnterMotion(
nextPresentation = routePresentationState.value,
previousPresentation?: typeof routePresentationState.value,
) {
routeEnterMotion.cancel()
if (pagePresentationMotion.start(nextPresentation.key, pageRouteRef.value)) return
if (pageMotionFrame) {
window.cancelAnimationFrame(pageMotionFrame)
pageMotionFrame = null
}
isPageEntering.value = false
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
pageMotionFrame = window.requestAnimationFrame(() => {
pageMotionFrame = null
isPageEntering.value = true
pageMotionTimer = window.setTimeout(() => {
isPageEntering.value = false
pageMotionTimer = null
}, 220)
routeEnterMotion.start(pageRouteRef.value, {
stagedHandoff: previousPresentation?.handoff === 'staged',
})
}
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
watch(routePresentationState, playPageEnterMotion, { flush: 'post' })
onMounted(playPageEnterMotion)
onBeforeUnmount(() => {
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
routeEnterMotion.cancel()
pagePresentationMotion.cancel()
})
</script>
@@ -61,7 +51,7 @@ onBeforeUnmount(() => {
<template>
<DefaultLayout>
<router-view v-slot="{ Component }">
<div ref="pageRouteRef" class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
<div ref="pageRouteRef" class="mp-page-route">
<keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive>

View File

@@ -50,6 +50,7 @@ const router = createRouter({
component: () => import('../pages/recommend.vue'),
meta: {
keepAlive: true,
pagePresentationHandoff: 'staged',
requiresAuth: true,
permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,

View File

@@ -131,4 +131,12 @@ describe('glass overlay material styles', () => {
/\[data-glass-scroll-presentation='native'\][\s\S]*?:where\([\s\S]*?--glass-native-surface-backdrop-filter/,
)
})
it('keeps frosted route opacity static while preserving its short movement', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
expect(styles).toMatch(
/\[data-glass-appearance='frosted'\]\[data-page-presentation-motion='active'\]\s+\.mp-page-route\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?transform:\s*translate3d\(0,\s*var\(--mp-page-motion-translate-y,\s*0\),\s*0\);/,
)
})
})

View File

@@ -266,25 +266,7 @@ html[data-theme-radius='extra'] {
.mp-page-route {
inline-size: 100%;
min-block-size: 100%;
}
.mp-page-route--entering {
animation: mp-page-route-enter var(--mp-motion-duration-page) var(--mp-motion-ease-standard) both;
will-change: opacity, transform;
}
@keyframes mp-page-route-enter {
from {
filter: blur(1px);
opacity: 0;
transform: translate3d(0, 0.5rem, 0) scale(0.992);
}
to {
filter: blur(0);
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
transform-origin: center top;
}
.mp-page-enter-active,
@@ -410,10 +392,6 @@ html[data-theme-radius='extra'] {
}
@media (prefers-reduced-motion: reduce) {
.mp-page-route--entering {
animation-duration: 1ms !important;
}
.mp-page-enter-active,
.mp-page-leave-active,
.mp-dialog-transition-enter-active,

View File

@@ -325,14 +325,6 @@ html[data-theme='glass'] {
}
// 页面 DOM 与 scroll renderer 共用同一 motion 时钟canvas 本身保持壁纸坐标不动。
.mp-page-route--entering {
animation: none;
filter: none;
opacity: 1;
transform: none;
will-change: auto;
}
&[data-page-presentation-motion='active'] .mp-page-route {
filter: none;
opacity: var(--mp-page-motion-opacity, 1);
@@ -341,6 +333,14 @@ html[data-theme='glass'] {
will-change: opacity, transform;
}
// 磨砂保持最终材质权重,只复用页面呈现时间线做短距离位移。
&[data-glass-appearance='frosted'][data-page-presentation-motion='active'] .mp-page-route {
filter: none;
opacity: 1;
transform: translate3d(0, var(--mp-page-motion-translate-y, 0), 0);
will-change: transform;
}
:where(
.app-surface:not(.no-blur),
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),