feat(short-drama-agent-front): 优化图片加载动效
This commit is contained in:
@@ -1,71 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/** 全局图片加载组件参数。 */
|
||||
interface Props {
|
||||
/** 图片地址。 */
|
||||
src: string
|
||||
/** 图片替代文本。 */
|
||||
alt: string
|
||||
/** 图片在容器内的适应方式。 */
|
||||
objectFit?: 'contain' | 'cover'
|
||||
/** 是否允许查看原图。 */
|
||||
preview?: boolean
|
||||
/** 是否启用浏览器原生懒加载。 */
|
||||
lazy?: boolean
|
||||
}
|
||||
|
||||
/** 全局图片加载组件事件。 */
|
||||
interface Emits {
|
||||
/** 图片加载失败事件。 */
|
||||
error: [event: Event]
|
||||
}
|
||||
|
||||
/** 图片展示参数。 */
|
||||
const props = withDefaults(defineProps<Props>(), { objectFit: 'cover', preview: false, lazy: true })
|
||||
|
||||
/** 图片加载事件。 */
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
/** 图片是否已经加载完成。 */
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 图片地址变化后恢复加载占位状态。 */
|
||||
function reset() {
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
/** 图片加载完成后直接切换为淡入状态。 */
|
||||
function reveal() {
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局图片加载动效:借鉴 Grid Reveal 的网格落图方式,但只使用 16 个 CSS 单元,
|
||||
* 避免列表中的每张缩略图都创建 Canvas、采样像素并持续运行 requestAnimationFrame。
|
||||
* 转发图片加载失败事件。
|
||||
* @param event 图片加载失败事件
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string
|
||||
alt: string
|
||||
objectFit?: 'contain' | 'cover'
|
||||
preview?: boolean
|
||||
lazy?: boolean
|
||||
}>(),
|
||||
{ objectFit: 'cover', preview: false, lazy: true }
|
||||
)
|
||||
const emit = defineEmits<{ error: [event: Event] }>()
|
||||
const loaded = ref(false)
|
||||
const settled = ref(false)
|
||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/** 让相邻方格错开退出,视觉上从中心和高关注区域向外展开。 */
|
||||
const cells = computed(() => {
|
||||
const order = [12, 8, 4, 13, 3, 0, 1, 9, 11, 2, 5, 14, 10, 6, 7, 15]
|
||||
return Array.from({ length: 16 }, (_, index) => ({
|
||||
index,
|
||||
order: order[index] ?? index,
|
||||
column: index % 4,
|
||||
row: Math.floor(index / 4)
|
||||
}))
|
||||
})
|
||||
|
||||
function reset() {
|
||||
clearTimeout(settleTimer)
|
||||
loaded.value = false
|
||||
settled.value = false
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
requestAnimationFrame(() => {
|
||||
loaded.value = true
|
||||
settleTimer = setTimeout(() => {
|
||||
settled.value = true
|
||||
}, 760)
|
||||
})
|
||||
}
|
||||
|
||||
function fail(event: Event) {
|
||||
clearTimeout(settleTimer)
|
||||
emit('error', event)
|
||||
}
|
||||
|
||||
function cellStyle(cell: (typeof cells.value)[number]) {
|
||||
return {
|
||||
'--reveal-order': cell.order,
|
||||
backgroundImage: loaded.value ? `url(${JSON.stringify(props.src)})` : undefined,
|
||||
backgroundPosition: `${(cell.column / 3) * 100}% ${(cell.row / 3) * 100}%`
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听图片地址变化并重置加载状态。 */
|
||||
watch(() => props.src, reset)
|
||||
onBeforeUnmount(() => clearTimeout(settleTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="reveal-image" :class="{ 'is-loaded': loaded, 'is-settled': settled }">
|
||||
<span class="reveal-image" :class="{ 'is-loaded': loaded }" :aria-busy="!loaded">
|
||||
<NImage
|
||||
:src="src"
|
||||
:preview-src="src"
|
||||
@@ -78,9 +67,8 @@ onBeforeUnmount(() => clearTimeout(settleTimer))
|
||||
@load="reveal"
|
||||
@error="fail"
|
||||
/>
|
||||
<span v-if="!settled" class="reveal-image-grid" aria-hidden="true">
|
||||
<span v-for="cell in cells" :key="cell.index" class="reveal-image-cell" :style="cellStyle(cell)" />
|
||||
</span>
|
||||
<!-- 以下是 Instagram 风格的图片加载骨架模块 -->
|
||||
<span v-if="!loaded" class="reveal-image-placeholder" aria-hidden="true" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -95,53 +83,27 @@ onBeforeUnmount(() => clearTimeout(settleTimer))
|
||||
}
|
||||
.reveal-image > .n-image img {
|
||||
opacity: 0;
|
||||
filter: blur(9px);
|
||||
transform: scale(1.025);
|
||||
transition:
|
||||
opacity 280ms ease,
|
||||
filter 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition: opacity 220ms cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.reveal-image.is-loaded > .n-image img {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
.reveal-image-grid {
|
||||
@apply absolute inset-0 grid grid-cols-4 grid-rows-4 pointer-events-none;
|
||||
gap: 1px;
|
||||
background: var(--app-body);
|
||||
}
|
||||
.reveal-image-cell {
|
||||
.reveal-image-placeholder {
|
||||
@apply absolute inset-0 pointer-events-none;
|
||||
background-color: var(--app-control);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 400% 400%;
|
||||
opacity: 1;
|
||||
transform: scale(1.01);
|
||||
transition:
|
||||
opacity 420ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition-delay: calc(var(--reveal-order) * 18ms);
|
||||
animation: reveal-image-pulse 1.2s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
|
||||
}
|
||||
.reveal-image:not(.is-loaded) .reveal-image-cell {
|
||||
animation: reveal-image-wait 1.8s ease-in-out infinite alternate;
|
||||
animation-delay: calc(var(--reveal-order) * -55ms);
|
||||
}
|
||||
.reveal-image.is-loaded .reveal-image-cell {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
@keyframes reveal-image-wait {
|
||||
@keyframes reveal-image-pulse {
|
||||
from {
|
||||
filter: brightness(0.96);
|
||||
opacity: 0.72;
|
||||
}
|
||||
to {
|
||||
filter: brightness(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-image > .n-image img,
|
||||
.reveal-image-cell {
|
||||
.reveal-image-placeholder {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user