mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 08:33:46 +08:00
fix(glass): preserve material during route transitions (#611)
This commit is contained in:
2
env.d.ts
vendored
2
env.d.ts
vendored
@@ -6,6 +6,8 @@ declare module 'vue-router' {
|
||||
subject?: string
|
||||
keepAlive?: boolean
|
||||
keepAliveKey?: string
|
||||
/** 来源页面停用成本较高时,分阶段把目标页起始态交给 compositor。 */
|
||||
pagePresentationHandoff?: 'staged'
|
||||
layoutWrapperClasses?: string
|
||||
navActiveLink?: RouteLocationRaw
|
||||
requiresAuth?: boolean
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
161
src/composables/__tests__/useRouteEnterMotion.spec.ts
Normal file
161
src/composables/__tests__/useRouteEnterMotion.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
116
src/composables/useRouteEnterMotion.ts
Normal file
116
src/composables/useRouteEnterMotion.ts
Normal 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() 会拒绝 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\);/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user