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
Vendored
+2
View File
@@ -6,6 +6,8 @@ declare module 'vue-router' {
subject?: string subject?: string
keepAlive?: boolean keepAlive?: boolean
keepAliveKey?: string keepAliveKey?: string
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
pagePresentationHandoff?: 'staged'
layoutWrapperClasses?: string layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw navActiveLink?: RouteLocationRaw
requiresAuth?: boolean requiresAuth?: boolean
@@ -474,15 +474,18 @@ describe('glass optical surface discovery', () => {
const bounds = { height: 240, width: 360, x: 80, y: 100 } const bounds = { height: 240, width: 360, x: 80, y: 100 }
appendOpticalSurface('app-hover-lift-card', bounds) appendOpticalSurface('app-hover-lift-card', bounds)
const active = ref(true) const active = ref(true)
const acknowledgeGeometryReady = vi.fn()
const epoch = ref(1)
const opacity = ref(1) const opacity = ref(1)
const revision = ref(0) const revision = ref(0)
const appearance = ref<'clear' | 'frosted'>('clear')
const scope = effectScope() const scope = effectScope()
const renderer = scope.run(() => const renderer = scope.run(() =>
useGlassOpticalRenderer({ useGlassOpticalRenderer({
active: ref(true), active: ref(true),
appearance: ref('clear'), appearance,
canvas: ref(canvas), canvas: ref(canvas),
pageMotion: { active, opacity, revision }, pageMotion: { acknowledgeGeometryReady, active, epoch, opacity, revision },
quality: ref('balanced'), quality: ref('balanced'),
routeKey: ref('/dashboard'), routeKey: ref('/dashboard'),
surfaceSpace: 'scroll', surfaceSpace: 'scroll',
@@ -519,6 +522,12 @@ describe('glass optical surface discovery', () => {
expect(uniforms.uRects.value[0].y).not.toBe(initialY) expect(uniforms.uRects.value[0].y).not.toBe(initialY)
expect(uniforms.uSurfaceWeights.value[0]).toBeCloseTo(0.42) 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)) const observer = ResizeObserverMock.instances.find(instance => instance.targets.has(root))
expect(observer).toBeDefined() expect(observer).toBeDefined()
setSize.mockClear() setSize.mockClear()
@@ -532,6 +541,67 @@ describe('glass optical surface discovery', () => {
scope.stop() 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 () => { it('recovers after consecutive WebGL context loss cycles', async () => {
const three = await import('three') const three = await import('three')
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
@@ -1,5 +1,6 @@
import { import {
getPagePresentationMotionProgress, getPagePresentationMotionProgress,
PAGE_PRESENTATION_FROSTED_START_TRANSLATE_Y,
PAGE_PRESENTATION_MOTION_DURATION_MS, PAGE_PRESENTATION_MOTION_DURATION_MS,
PAGE_PRESENTATION_MOTION_START_OPACITY, PAGE_PRESENTATION_MOTION_START_OPACITY,
PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y, PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y,
@@ -16,6 +17,8 @@ beforeEach(() => {
callbacks = new Map() callbacks = new Map()
frameId = 0 frameId = 0
document.documentElement.dataset.theme = 'glass' document.documentElement.dataset.theme = 'glass'
document.documentElement.dataset.glassAppearance = 'clear'
document.documentElement.dataset.glassQuality = 'high'
delete document.documentElement.dataset.launchLoading delete document.documentElement.dataset.launchLoading
vi.spyOn(performance, 'now').mockReturnValue(1000) vi.spyOn(performance, 'now').mockReturnValue(1000)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
@@ -33,6 +36,8 @@ afterEach(() => {
motion.cancel() motion.cancel()
document.getElementById('loading-bg')?.remove() document.getElementById('loading-bg')?.remove()
delete document.documentElement.dataset.theme delete document.documentElement.dataset.theme
delete document.documentElement.dataset.glassAppearance
delete document.documentElement.dataset.glassQuality
delete document.documentElement.dataset.launchLoading delete document.documentElement.dataset.launchLoading
delete document.documentElement.dataset.pagePresentationMotion delete document.documentElement.dataset.pagePresentationMotion
document.documentElement.style.removeProperty('--mp-page-motion-opacity') document.documentElement.style.removeProperty('--mp-page-motion-opacity')
@@ -40,6 +45,25 @@ afterEach(() => {
}) })
describe('page presentation motion', () => { 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', () => { it('does not add a second reveal gate behind the initial launch screen', () => {
document.documentElement.dataset.launchLoading = 'true' document.documentElement.dataset.launchLoading = 'true'
const launchScreen = document.createElement('div') const launchScreen = document.createElement('div')
@@ -95,6 +119,58 @@ describe('page presentation motion', () => {
routeRoot.remove() 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', () => { it('uses one eased timeline for the initial, intermediate, and settled states', () => {
const initialRevision = motion.revision.value const initialRevision = motion.revision.value
@@ -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()
})
})
+27 -4
View File
@@ -1393,6 +1393,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let surfaceTransformTrackingDeadline = 0 let surfaceTransformTrackingDeadline = 0
const transformingSurfaces = new Set<HTMLElement>() const transformingSurfaces = new Set<HTMLElement>()
let pagePresentationGeometryReady = true let pagePresentationGeometryReady = true
let pagePresentationMotionEpoch: number | null = null
let wakeDirection = { x: 0, y: -1 } let wakeDirection = { x: 0, y: -1 }
let contextRecoveryPending = false let contextRecoveryPending = false
let resumePromise: Promise<void> | null = null let resumePromise: Promise<void> | null = null
@@ -1889,7 +1890,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS) ? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 } : { incoming: 1, outgoing: 0 }
const pageMotionOpacity = 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 const pagePresentationWeight = pagePresentationGeometryReady ? pageMotionOpacity : 0
for (let index = 0; index < 8; index += 1) { for (let index = 0; index < 8; index += 1) {
@@ -2114,7 +2117,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */ /** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
function scheduleSurfaceStabilityUpdate() { function scheduleSurfaceStabilityUpdate(motionEpoch?: number) {
if (motionEpoch !== undefined) pagePresentationMotionEpoch = motionEpoch
if (queueScrollGeometryRefresh(true)) return if (queueScrollGeometryRefresh(true)) return
surfaceStabilityPass = 0 surfaceStabilityPass = 0
surfaceStableFrameCount = 0 surfaceStableFrameCount = 0
@@ -2147,6 +2151,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
pagePresentationGeometryReady = true pagePresentationGeometryReady = true
writeSurfaceUniforms(timestamp) writeSurfaceUniforms(timestamp)
renderFrame(timestamp, false) 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' }, { 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( watch(
() => toValue(options.routeKey), () => toValue(options.routeKey),
async (routeKey, previousRouteKey) => { async (routeKey, previousRouteKey) => {
@@ -3951,6 +3972,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (previousKey !== nextKey) invalidatePreparedWallpaper() if (previousKey !== nextKey) invalidatePreparedWallpaper()
if (resources && presentationSpace === 'scroll' && options.pageMotion) { if (resources && presentationSpace === 'scroll' && options.pageMotion) {
pagePresentationGeometryReady = false pagePresentationGeometryReady = false
pagePresentationMotionEpoch = toValue(options.pageMotion.epoch)
const timestamp = performance.now() const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false) updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false) renderFrame(timestamp, false)
@@ -3966,8 +3988,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
return return
} }
} }
if (presentationSpace === 'scroll' && options.pageMotion) scheduleSurfaceStabilityUpdate() if (presentationSpace === 'scroll' && options.pageMotion) {
else scheduleSurfaceUpdate() scheduleSurfaceStabilityUpdate(toValue(options.pageMotion.epoch))
} else scheduleSurfaceUpdate()
}, },
) )
+42 -10
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_DURATION_MS = 180
export const PAGE_PRESENTATION_MOTION_START_OPACITY = 0.88 export const PAGE_PRESENTATION_MOTION_START_OPACITY = 0.88
export const PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y = 4 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_STABLE_MS = 120
export const PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS = 480 export const PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS = 480
/** renderer 只读取同一帧已经提交到 DOM 的页面呈现状态。 */ /** renderer 只读取同一帧已经提交到 DOM 的页面呈现状态。 */
export interface PagePresentationMotionReader { export interface PagePresentationMotionReader {
/** renderer 确认当前事务的 surface 几何已稳定后,允许页面开始 reveal。 */
acknowledgeGeometryReady: (motionEpoch: number, timestamp?: number) => boolean
/** 页面是否处于共享呈现事务中。 */ /** 页面是否处于共享呈现事务中。 */
active: Readonly<Ref<boolean>> active: Readonly<Ref<boolean>>
/** 当前页面材质与 DOM 共同使用的透明度。 */ /** 当前呈现事务版本;旧 surface 采样不得完成新事务。 */
epoch: Readonly<Ref<number>>
/** 页面内容的呈现透明度;renderer 按材质合成约束决定是否使用。 */
opacity: Readonly<Ref<number>> opacity: Readonly<Ref<number>>
/** 每次 DOM motion 样式提交后递增,renderer 据此在同一帧刷新表面。 */ /** 每次 DOM motion 样式提交后递增,renderer 据此在同一帧刷新表面。 */
revision: Readonly<Ref<number>> revision: Readonly<Ref<number>>
@@ -27,7 +32,9 @@ let animationFrame: number | null = null
let layoutHoldStartedAt = 0 let layoutHoldStartedAt = 0
let layoutStableSince = 0 let layoutStableSince = 0
let layoutSignature = '' let layoutSignature = ''
let motionStartTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y
let startedAt = 0 let startedAt = 0
let preserveFrostedMaterial = false
function sampleBezier(time: number, start: number, end: number) { function sampleBezier(time: number, start: number, end: number) {
const inverse = 1 - time const inverse = 1 - time
@@ -64,9 +71,10 @@ function clearDocumentMotionState() {
/** 先提交 DOM 样式,再发布 revision,保证 renderer 读取到同一帧的真实矩形。 */ /** 先提交 DOM 样式,再发布 revision,保证 renderer 读取到同一帧的真实矩形。 */
function applyMotionFrame(nextProgress: number) { function applyMotionFrame(nextProgress: number) {
const root = document.documentElement const root = document.documentElement
const nextOpacity = const nextOpacity = preserveFrostedMaterial
PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress ? 1
const nextTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y * (1 - nextProgress) : PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress
const nextTranslateY = motionStartTranslateY * (1 - nextProgress)
root.dataset.pagePresentationMotion = 'active' root.dataset.pagePresentationMotion = 'active'
root.style.setProperty('--mp-page-motion-opacity', nextOpacity.toFixed(4)) root.style.setProperty('--mp-page-motion-opacity', nextOpacity.toFixed(4))
@@ -82,11 +90,11 @@ function applyLayoutHoldFrame() {
const root = document.documentElement const root = document.documentElement
root.dataset.pagePresentationMotion = 'active' root.dataset.pagePresentationMotion = 'active'
root.style.setProperty('--mp-page-motion-opacity', '0') root.style.setProperty('--mp-page-motion-opacity', preserveFrostedMaterial ? '1' : '0')
root.style.setProperty('--mp-page-motion-translate-y', `${PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y}px`) root.style.setProperty('--mp-page-motion-translate-y', `${motionStartTranslateY}px`)
opacity.value = 0 opacity.value = preserveFrostedMaterial ? 1 : 0
progress.value = 0 progress.value = 0
translateY.value = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y translateY.value = motionStartTranslateY
revision.value += 1 revision.value += 1
} }
@@ -102,6 +110,17 @@ function beginReveal(timestamp: number, motionEpoch: number) {
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch)) 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;上限避免持续布局页面永久不可见。 */ /** 页面根持续稳定后才开始 reveal;上限避免持续布局页面永久不可见。 */
function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElement) { function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElement) {
if (!active.value || epoch.value !== motionEpoch) return if (!active.value || epoch.value !== motionEpoch) return
@@ -161,7 +180,7 @@ function renderFrame(timestamp: number, motionEpoch: number) {
} }
/** /**
* 玻璃主题由共享控制器接管页面入场;其他主题继续使用既有 CSS keyframe * 需要 renderer 同步或保持磨砂密度的玻璃页面由共享控制器接管;其他页面交给普通 WAAPI
* 返回 true 表示本次路由变化已经处理,包括 reduced-motion 的即时提交。 * 返回 true 表示本次路由变化已经处理,包括 reduced-motion 的即时提交。
*/ */
function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) { function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
@@ -175,6 +194,17 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
epoch.value += 1 epoch.value += 1
const motionEpoch = epoch.value const motionEpoch = epoch.value
routeKey.value = nextRouteKey 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')) { if (document.documentElement.dataset.launchLoading === 'true' && document.getElementById('loading-bg')) {
@@ -191,7 +221,7 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
active.value = true active.value = true
const timestamp = performance.now() const timestamp = performance.now()
if (layoutRoot) { if (layoutRoot && !usesCssQuality) {
layoutHoldStartedAt = timestamp layoutHoldStartedAt = timestamp
layoutStableSince = timestamp layoutStableSince = timestamp
layoutSignature = getLayoutSignature(layoutRoot) layoutSignature = getLayoutSignature(layoutRoot)
@@ -207,7 +237,9 @@ function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
} }
const reader: PagePresentationMotionReader = { const reader: PagePresentationMotionReader = {
acknowledgeGeometryReady,
active: readonly(active), active: readonly(active),
epoch: readonly(epoch),
opacity: readonly(opacity), opacity: readonly(opacity),
revision: readonly(revision), revision: readonly(revision),
} }
+116
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,
}
}
+18 -28
View File
@@ -1,9 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import DefaultLayout from './default/components/DefaultLayout.vue' import DefaultLayout from './default/components/DefaultLayout.vue'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion' import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import { useRouteEnterMotion } from '@/composables/useRouteEnterMotion'
const route = useRoute() const route = useRoute()
const pagePresentationMotion = usePagePresentationMotion() const pagePresentationMotion = usePagePresentationMotion()
const routeEnterMotion = useRouteEnterMotion()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。 // keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => { const routeCacheKey = computed(() => {
@@ -17,43 +19,31 @@ const routeCacheKey = computed(() => {
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。 // 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath)) 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) const pageRouteRef = ref<HTMLElement | null>(null)
let pageMotionTimer: number | null = null
let pageMotionFrame: number | null = null
// 使用稳定容器触发轻量入场动画,避免重建 keep-alive 导致页面缓存失效 // 默认布局只编排路由事务;普通页面与玻璃材质分别由各自 driver 执行动画
function playPageEnterMotion() { function playPageEnterMotion(
if (pageMotionTimer) { nextPresentation = routePresentationState.value,
window.clearTimeout(pageMotionTimer) previousPresentation?: typeof routePresentationState.value,
pageMotionTimer = null ) {
} routeEnterMotion.cancel()
if (pagePresentationMotion.start(nextPresentation.key, pageRouteRef.value)) return
if (pageMotionFrame) { routeEnterMotion.start(pageRouteRef.value, {
window.cancelAnimationFrame(pageMotionFrame) stagedHandoff: previousPresentation?.handoff === 'staged',
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)
}) })
} }
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' }) watch(routePresentationState, playPageEnterMotion, { flush: 'post' })
onMounted(playPageEnterMotion) onMounted(playPageEnterMotion)
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (pageMotionTimer) window.clearTimeout(pageMotionTimer) routeEnterMotion.cancel()
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
pagePresentationMotion.cancel() pagePresentationMotion.cancel()
}) })
</script> </script>
@@ -61,7 +51,7 @@ onBeforeUnmount(() => {
<template> <template>
<DefaultLayout> <DefaultLayout>
<router-view v-slot="{ Component }"> <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"> <keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" /> <component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive> </keep-alive>
+1
View File
@@ -50,6 +50,7 @@ const router = createRouter({
component: () => import('../pages/recommend.vue'), component: () => import('../pages/recommend.vue'),
meta: { meta: {
keepAlive: true, keepAlive: true,
pagePresentationHandoff: 'staged',
requiresAuth: true, requiresAuth: true,
permission: 'discovery', permission: 'discovery',
feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND, feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
@@ -131,4 +131,12 @@ describe('glass overlay material styles', () => {
/\[data-glass-scroll-presentation='native'\][\s\S]*?:where\([\s\S]*?--glass-native-surface-backdrop-filter/, /\[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\);/,
)
})
}) })
+1 -23
View File
@@ -266,25 +266,7 @@ html[data-theme-radius='extra'] {
.mp-page-route { .mp-page-route {
inline-size: 100%; inline-size: 100%;
min-block-size: 100%; min-block-size: 100%;
} transform-origin: center top;
.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);
}
} }
.mp-page-enter-active, .mp-page-enter-active,
@@ -410,10 +392,6 @@ html[data-theme-radius='extra'] {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.mp-page-route--entering {
animation-duration: 1ms !important;
}
.mp-page-enter-active, .mp-page-enter-active,
.mp-page-leave-active, .mp-page-leave-active,
.mp-dialog-transition-enter-active, .mp-dialog-transition-enter-active,
+8 -8
View File
@@ -325,14 +325,6 @@ html[data-theme='glass'] {
} }
// 页面 DOM 与 scroll renderer 共用同一 motion 时钟;canvas 本身保持壁纸坐标不动。 // 页面 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 { &[data-page-presentation-motion='active'] .mp-page-route {
filter: none; filter: none;
opacity: var(--mp-page-motion-opacity, 1); opacity: var(--mp-page-motion-opacity, 1);
@@ -341,6 +333,14 @@ html[data-theme='glass'] {
will-change: opacity, transform; 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( :where(
.app-surface:not(.no-blur), .app-surface:not(.no-blur),
.v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error), .v-card:not(.no-blur, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error),