mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 08:46:40 +08:00
fix(glass): preserve material during route transitions (#611)
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-28
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user