1127 lines
31 KiB
Vue
1127 lines
31 KiB
Vue
|
|
<template>
|
|||
|
|
<view
|
|||
|
|
v-if="visible"
|
|||
|
|
class="iv-root"
|
|||
|
|
:class="{ 'iv-root--closing': animPhase === 'exiting' }"
|
|||
|
|
:style="{ ...rootStyle, ...animationStyles.root }"
|
|||
|
|
@click="animPhase !== 'exiting' && onOverlayClick()"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
class="iv-backdrop"
|
|||
|
|
:class="{ 'iv-backdrop--expanded': expanded, 'iv-backdrop--closing': animPhase === 'exiting' }"
|
|||
|
|
:style="animationStyles.backdrop"
|
|||
|
|
@click.stop
|
|||
|
|
/>
|
|||
|
|
<!-- 图片层 -->
|
|||
|
|
<view
|
|||
|
|
class="iv-transform-wrap"
|
|||
|
|
:style="{ ...transformWrapStyle, ...animationStyles.transformWrap }"
|
|||
|
|
@click.stop="animPhase !== 'exiting' && requestClose()"
|
|||
|
|
>
|
|||
|
|
<!-- 单图 / 未展开 / 正在关闭 - 单块内容,多图关闭时也走缩小动画 -->
|
|||
|
|
<view
|
|||
|
|
v-if="showSingleContent"
|
|||
|
|
class="iv-pinch-wrap"
|
|||
|
|
:style="pinchWrapStyle"
|
|||
|
|
@touchstart="onPinchTouchStart"
|
|||
|
|
@touchmove="onPinchTouchMove"
|
|||
|
|
@touchend="onPinchTouchEnd"
|
|||
|
|
@touchcancel="onPinchTouchEnd"
|
|||
|
|
>
|
|||
|
|
<image
|
|||
|
|
:src="currentUrl"
|
|||
|
|
class="iv-img"
|
|||
|
|
mode="aspectFit"
|
|||
|
|
@load="onImageLoad(currentIndex)"
|
|||
|
|
@error="onImageError(currentIndex)"
|
|||
|
|
/>
|
|||
|
|
<!-- 加载失败提示 -->
|
|||
|
|
<view v-if="imageLoadStates[currentIndex] === 'error'" class="iv-error">
|
|||
|
|
<text class="iv-error-text">图片加载失败</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<!-- 多图已展开 -->
|
|||
|
|
<view v-else-if="showSwiper" class="iv-slides-view">
|
|||
|
|
<view class="iv-slides-track" :style="slidesTrackStyle">
|
|||
|
|
<view
|
|||
|
|
v-for="(url, i) in urls"
|
|||
|
|
:key="i"
|
|||
|
|
class="iv-slide"
|
|||
|
|
:style="{ width: slideWidthPx + 'px' }"
|
|||
|
|
:v-show="isSlideVisible(i)"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
class="iv-pinch-wrap"
|
|||
|
|
:style="getPinchWrapStyleForIndex(i)"
|
|||
|
|
@touchstart="onSlideTouchStart"
|
|||
|
|
@touchmove="onSlideTouchMove"
|
|||
|
|
@touchend="onSlideTouchEnd"
|
|||
|
|
@touchcancel="onSlideTouchEnd"
|
|||
|
|
>
|
|||
|
|
<image
|
|||
|
|
:src="url"
|
|||
|
|
class="iv-slide-img"
|
|||
|
|
mode="aspectFit"
|
|||
|
|
@load="onImageLoad(i)"
|
|||
|
|
@error="onImageError(i)"
|
|||
|
|
/>
|
|||
|
|
<!-- 加载失败提示 -->
|
|||
|
|
<view v-if="imageLoadStates[i] === 'error'" class="iv-error">
|
|||
|
|
<text class="iv-error-text">图片加载失败</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<!-- 加载指示器:放 transform-wrap 外侧,避免被 blur 模糊掉,延迟显示避免缓存命中闪一下 -->
|
|||
|
|
<view
|
|||
|
|
v-if="expanded && showLoadingOverlay && animPhase !== 'exiting'"
|
|||
|
|
class="iv-loading"
|
|||
|
|
>
|
|||
|
|
<view class="iv-loading-spinner"></view>
|
|||
|
|
</view>
|
|||
|
|
<view v-if="showSwiper" class="iv-dots-wrap" @click.stop="requestClose">
|
|||
|
|
<view class="iv-dots">
|
|||
|
|
<text class="iv-dots__text">{{ currentIndex + 1 }} / {{ urls.length }}</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
/**
|
|||
|
|
* ImageViewer:单图/多图、打开从原图尺寸到全屏、关闭缩回+渐隐。
|
|||
|
|
* - 单图:一块 pinch 区域,双指缩放/单指拖动。
|
|||
|
|
* - 多图:自定义滑动条(非原生 swiper),v-show 只显示相邻项;每张图独立管理缩放;未放大时跟手滑动,放大时边缘再切图。
|
|||
|
|
*/
|
|||
|
|
import { ref, computed, watch, inject, nextTick, onUnmounted, reactive } from 'vue'
|
|||
|
|
|
|||
|
|
export type InitialRect = { left: number; top: number; width: number; height: number }
|
|||
|
|
|
|||
|
|
// ==================== 配置常量 ====================
|
|||
|
|
/** 最小缩放比例 */
|
|||
|
|
const MIN_PINCH_SCALE = 1
|
|||
|
|
/** 最大缩放比例 */
|
|||
|
|
const MAX_PINCH_SCALE = 8
|
|||
|
|
/** 双指最小距离(像素),小于此距离不响应缩放 */
|
|||
|
|
const MIN_PINCH_DIST = 24
|
|||
|
|
/** 边缘滑动切换图片的阈值(像素) */
|
|||
|
|
const EDGE_OVERFLOW_THRESHOLD = 28
|
|||
|
|
/** 边缘检测的边距(像素) */
|
|||
|
|
const EDGE_MARGIN = 2
|
|||
|
|
/** 滑动切换图片的拖拽阈值(像素) */
|
|||
|
|
const SLIDE_DRAG_THRESHOLD = 60
|
|||
|
|
/** 滑动动画时长(毫秒) */
|
|||
|
|
const SLIDE_ANIMATION_MS = 200
|
|||
|
|
/** 滑动动画缓动函数(快进慢出) */
|
|||
|
|
const SLIDE_EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
|
|||
|
|
/** 滑动拖拽系数,>1 更轻便,<1 更阻尼 */
|
|||
|
|
const SLIDE_DRAG_FACTOR = 1.35
|
|||
|
|
/** 打开时模糊+透明度动画时长(秒) */
|
|||
|
|
const OPEN_BLUR_S = 0.25
|
|||
|
|
/** 打开时最大模糊值(像素) */
|
|||
|
|
const OPEN_BLUR_MAX = 20
|
|||
|
|
/** 打开动画缓动函数 */
|
|||
|
|
const OPEN_ANIMATION_EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
|||
|
|
/** 关闭时模糊+透明度动画时长(秒) */
|
|||
|
|
const EXIT_BLUR_S = 0.4
|
|||
|
|
/** 关闭时最大模糊值(像素) */
|
|||
|
|
const EXIT_BLUR_MAX = 100
|
|||
|
|
/** 加载指示器延迟显示(毫秒),避免缓存命中时闪一下 */
|
|||
|
|
const LOADING_INDICATOR_DELAY_MS = 100
|
|||
|
|
|
|||
|
|
const props = withDefaults(
|
|||
|
|
defineProps<{
|
|||
|
|
visible: boolean
|
|||
|
|
urls: string[]
|
|||
|
|
current?: number
|
|||
|
|
initialRect?: InitialRect | null
|
|||
|
|
zIndex?: number
|
|||
|
|
}>(),
|
|||
|
|
{ current: 0, initialRect: null, zIndex: 9999 }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
close: []
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const tabRootBackPress = inject<{
|
|||
|
|
register: (h: { check: () => boolean; close: () => void }) => () => void
|
|||
|
|
}>('tabRootBackPress', undefined as any)
|
|||
|
|
|
|||
|
|
// ==================== 动画状态机 ====================
|
|||
|
|
/**
|
|||
|
|
* 动画流程:启动动画 -> 模糊按加载状态 -> 加载完成后模糊动画 -> 退出动画
|
|||
|
|
* - entering: 根/背景/内容 opacity 0→1,内容保持 blur(20)
|
|||
|
|
* - displaying: 透明度已到 1,内容 blur 由加载状态决定:loading 保持 20px,loaded 时 blur 20→0
|
|||
|
|
* - exiting: blur(100) + opacity 0
|
|||
|
|
* 注意:首帧必须先渲染 opacity:0,下一帧再应用 opacity:1+transition,否则浏览器不会播放透明度过渡。
|
|||
|
|
*/
|
|||
|
|
type AnimPhase = 'entering' | 'displaying' | 'exiting'
|
|||
|
|
const animPhase = ref<AnimPhase | null>(null)
|
|||
|
|
/** 进入阶段是否已过首帧,用于延迟应用「目标样式」以触发 CSS transition */
|
|||
|
|
const enterPaintReady = ref(false)
|
|||
|
|
/** 目标样式首帧已绘制后为 true,此后 blur 随 loaded 变化,保证跟手 */
|
|||
|
|
const enterTargetPainted = ref(false)
|
|||
|
|
let enterCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
|||
|
|
let exitCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
|||
|
|
|
|||
|
|
function clearAnimTimers() {
|
|||
|
|
if (enterCompleteTimer) {
|
|||
|
|
clearTimeout(enterCompleteTimer)
|
|||
|
|
enterCompleteTimer = null
|
|||
|
|
}
|
|||
|
|
if (exitCompleteTimer) {
|
|||
|
|
clearTimeout(exitCompleteTimer)
|
|||
|
|
exitCompleteTimer = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 下一帧执行(小程序等环境无 requestAnimationFrame,用 setTimeout 兜底) */
|
|||
|
|
function nextFrame(fn: () => void) {
|
|||
|
|
if (typeof requestAnimationFrame !== 'undefined') {
|
|||
|
|
requestAnimationFrame(fn)
|
|||
|
|
} else {
|
|||
|
|
setTimeout(fn, 0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startEnterPhase() {
|
|||
|
|
animPhase.value = 'entering'
|
|||
|
|
enterPaintReady.value = false
|
|||
|
|
enterTargetPainted.value = false
|
|||
|
|
clearAnimTimers()
|
|||
|
|
nextFrame(() => {
|
|||
|
|
enterPaintReady.value = true
|
|||
|
|
nextFrame(() => {
|
|||
|
|
enterTargetPainted.value = true
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
enterCompleteTimer = setTimeout(() => {
|
|||
|
|
enterCompleteTimer = null
|
|||
|
|
animPhase.value = 'displaying'
|
|||
|
|
}, OPEN_BLUR_S * 1000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startExitPhase(onComplete: () => void) {
|
|||
|
|
animPhase.value = 'exiting'
|
|||
|
|
clearAnimTimers()
|
|||
|
|
exitCompleteTimer = setTimeout(() => {
|
|||
|
|
exitCompleteTimer = null
|
|||
|
|
onComplete()
|
|||
|
|
}, EXIT_BLUR_S * 1000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 响应式状态 ====================
|
|||
|
|
const expanded = ref(false)
|
|||
|
|
const currentIndex = ref(0)
|
|||
|
|
const windowSize = ref({ width: 375, height: 667 })
|
|||
|
|
let unregisterBack: (() => void) | null = null
|
|||
|
|
|
|||
|
|
// ==================== 图片加载状态 ====================
|
|||
|
|
/** 每张图片的加载状态:loading | loaded | error */
|
|||
|
|
const imageLoadStates = reactive<Record<number, 'loading' | 'loaded' | 'error'>>({})
|
|||
|
|
|
|||
|
|
/** 当前显示的图片是否已加载完成 */
|
|||
|
|
const isCurrentImageLoaded = computed(() => {
|
|||
|
|
return imageLoadStates[currentIndex.value] === 'loaded'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 当前有内容可展示(加载中/已加载/错误),用于显示内容区,否则父级 opacity:0 会隐藏加载动画 */
|
|||
|
|
const hasContentToShow = computed(() => {
|
|||
|
|
const state = imageLoadStates[currentIndex.value]
|
|||
|
|
return state === 'loading' || state === 'loaded' || state === 'error'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 加载指示器是否显示:进入 loading 后延迟 0.1s 再显示,避免重新点开时闪一下 */
|
|||
|
|
const showLoadingOverlay = ref(false)
|
|||
|
|
let loadingIndicatorTimer: ReturnType<typeof setTimeout> | null = null
|
|||
|
|
|
|||
|
|
function clearLoadingIndicatorTimer() {
|
|||
|
|
if (loadingIndicatorTimer) {
|
|||
|
|
clearTimeout(loadingIndicatorTimer)
|
|||
|
|
loadingIndicatorTimer = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 双指缩放与单指平移
|
|||
|
|
const pinchScale = ref(1)
|
|||
|
|
const pinchX = ref(0)
|
|||
|
|
const pinchY = ref(0)
|
|||
|
|
// ==================== 状态管理 ====================
|
|||
|
|
/** 单指拖动目标位置(用于状态同步) */
|
|||
|
|
let targetPanX = 0
|
|||
|
|
let targetPanY = 0
|
|||
|
|
let targetDragOffset = 0
|
|||
|
|
|
|||
|
|
const DEFAULT_PINCH = { scale: 1, x: 0, y: 0 } as const
|
|||
|
|
|
|||
|
|
// ==================== 手势状态 ====================
|
|||
|
|
/** 双指缩放开始状态 */
|
|||
|
|
let pinchStart: {
|
|||
|
|
distance: number
|
|||
|
|
centerX: number
|
|||
|
|
centerY: number
|
|||
|
|
baseScale: number
|
|||
|
|
baseX: number
|
|||
|
|
baseY: number
|
|||
|
|
lastCenterX?: number
|
|||
|
|
} | null = null
|
|||
|
|
|
|||
|
|
/** 单指拖动开始状态 */
|
|||
|
|
let panStart: {
|
|||
|
|
startX: number
|
|||
|
|
startY: number
|
|||
|
|
baseX: number
|
|||
|
|
baseY: number
|
|||
|
|
lastX: number
|
|||
|
|
lastY: number
|
|||
|
|
baseDragOffset?: number
|
|||
|
|
} | null = null
|
|||
|
|
|
|||
|
|
/** 边缘滑动溢出累计值 */
|
|||
|
|
let edgeOverflow = 0
|
|||
|
|
|
|||
|
|
// ==================== 多图状态管理 ====================
|
|||
|
|
/** 每张图独立的缩放/平移状态 */
|
|||
|
|
const pinchStateByIndex = reactive<Record<number, { scale: number; x: number; y: number }>>({})
|
|||
|
|
|
|||
|
|
function getPinchState(i: number) {
|
|||
|
|
return pinchStateByIndex[i] ?? DEFAULT_PINCH
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setPinchState(i: number, s: { scale: number; x: number; y: number }) {
|
|||
|
|
pinchStateByIndex[i] = { ...s }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 滑动条状态 ====================
|
|||
|
|
/** 滑动条拖拽偏移量(像素) */
|
|||
|
|
const dragOffsetPx = ref(0)
|
|||
|
|
/** 是否启用滑动过渡动画 */
|
|||
|
|
const slideTransitionOn = ref(false)
|
|||
|
|
|
|||
|
|
const slideWidthPx = computed(() => windowSize.value.width)
|
|||
|
|
const slidesTrackStyle = computed(() => {
|
|||
|
|
const w = windowSize.value.width
|
|||
|
|
const n = props.urls?.length ?? 0
|
|||
|
|
const tx = n <= 0 ? 0 : -currentIndex.value * w + dragOffsetPx.value
|
|||
|
|
const trans = slideTransitionOn.value ? `transform ${SLIDE_ANIMATION_MS}ms ${SLIDE_EASE}` : 'none'
|
|||
|
|
return {
|
|||
|
|
width: `${n * w}px`,
|
|||
|
|
transform: `translateX(${tx}px)`,
|
|||
|
|
transition: trans
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
function isSlideVisible(i: number) {
|
|||
|
|
const c = currentIndex.value
|
|||
|
|
return i >= c - 1 && i <= c + 1
|
|||
|
|
}
|
|||
|
|
function getPinchWrapStyleForIndex(i: number) {
|
|||
|
|
const s = getPinchState(i)
|
|||
|
|
return { transform: `translate(${s.x}px, ${s.y}px) scale(${s.scale})` }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pinchWrapStyle = computed(() => ({
|
|||
|
|
transform: `translate(${pinchX.value}px, ${pinchY.value}px) scale(${pinchScale.value})`
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
// ==================== 边界计算 ====================
|
|||
|
|
/** 根据缩放与视口算平移边界(单图/多图共用) */
|
|||
|
|
function getBounds(scale: number, w: number, h: number) {
|
|||
|
|
const maxX = (w * (scale - 1)) / 2
|
|||
|
|
const maxY = (h * (scale - 1)) / 2
|
|||
|
|
return { maxX: Math.max(0, maxX), maxY: Math.max(0, maxY) }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getPinchBounds() {
|
|||
|
|
const { width: w, height: h } = windowSize.value
|
|||
|
|
return getBounds(pinchScale.value, w, h)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 缩放变化后把平移限制在合法范围内,避免缩小后卡住 */
|
|||
|
|
function clampTranslateToBounds() {
|
|||
|
|
const s = pinchScale.value
|
|||
|
|
if (s <= 1) {
|
|||
|
|
pinchX.value = 0
|
|||
|
|
pinchY.value = 0
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const { maxX, maxY } = getPinchBounds()
|
|||
|
|
pinchX.value = Math.max(-maxX, Math.min(maxX, pinchX.value))
|
|||
|
|
pinchY.value = Math.max(-maxY, Math.min(maxY, pinchY.value))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getDistance(touches: { clientX: number; clientY: number }[]) {
|
|||
|
|
if (touches.length < 2) return 0
|
|||
|
|
const dx = touches[1].clientX - touches[0].clientX
|
|||
|
|
const dy = touches[1].clientY - touches[0].clientY
|
|||
|
|
return Math.hypot(dx, dy) || 1
|
|||
|
|
}
|
|||
|
|
function getCenter(touches: { clientX: number; clientY: number }[]) {
|
|||
|
|
if (touches.length < 2) return { x: touches[0].clientX, y: touches[0].clientY }
|
|||
|
|
return {
|
|||
|
|
x: (touches[0].clientX + touches[1].clientX) / 2,
|
|||
|
|
y: (touches[0].clientY + touches[1].clientY) / 2
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 工具函数 ====================
|
|||
|
|
/** 限制值在范围内 */
|
|||
|
|
function clamp(value: number, min: number, max: number): number {
|
|||
|
|
return Math.max(min, Math.min(max, value))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 计算并限制平移位置在边界内 */
|
|||
|
|
function clampPosition(x: number, y: number, maxX: number, maxY: number): { x: number; y: number } {
|
|||
|
|
return {
|
|||
|
|
x: clamp(x, -maxX, maxX),
|
|||
|
|
y: clamp(y, -maxY, maxY)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 边缘滑动切图:在贴边且朝切图方向滑动时累加,达到阈值返回 'prev'|'next',否则重置并返回 null */
|
|||
|
|
function tryEdgeSwitch(newX: number, maxX: number, deltaX: number): 'prev' | 'next' | null {
|
|||
|
|
if (maxX <= EDGE_MARGIN) return null
|
|||
|
|
if (newX >= maxX - EDGE_MARGIN && deltaX < 0) {
|
|||
|
|
edgeOverflow += Math.abs(deltaX)
|
|||
|
|
if (edgeOverflow >= EDGE_OVERFLOW_THRESHOLD) {
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
return 'next'
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
if (newX <= -maxX + EDGE_MARGIN && deltaX > 0) {
|
|||
|
|
edgeOverflow += Math.abs(deltaX)
|
|||
|
|
if (edgeOverflow >= EDGE_OVERFLOW_THRESHOLD) {
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
return 'prev'
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 手势处理 ====================
|
|||
|
|
/** 处理双指缩放(以双指中心为缩放中心) */
|
|||
|
|
function handlePinchZoom(touches: any[], isMultiMode: boolean, curIdx?: number) {
|
|||
|
|
if (!pinchStart) return
|
|||
|
|
|
|||
|
|
const dist = Math.max(getDistance(touches), MIN_PINCH_DIST)
|
|||
|
|
const center = getCenter(touches)
|
|||
|
|
const baseDist = Math.max(pinchStart.distance, MIN_PINCH_DIST)
|
|||
|
|
const scale = clamp((dist / baseDist) * pinchStart.baseScale, MIN_PINCH_SCALE, MAX_PINCH_SCALE)
|
|||
|
|
|
|||
|
|
const { width: w, height: h } = windowSize.value
|
|||
|
|
const scaleRatio = scale / pinchStart.baseScale
|
|||
|
|
|
|||
|
|
// 计算以双指中心为缩放中心的新位置
|
|||
|
|
// 缩放前:图片中心在 baseX, baseY,双指中心在 centerX, centerY
|
|||
|
|
// 缩放后:要保持双指中心在图片上的相对位置不变
|
|||
|
|
// newX = baseX * scaleRatio + (centerX - w/2) * (1 - scaleRatio) + (centerX - startCenterX)
|
|||
|
|
const wHalf = w / 2
|
|||
|
|
const hHalf = h / 2
|
|||
|
|
|
|||
|
|
// 双指中心的移动量
|
|||
|
|
const deltaCenterX = center.x - pinchStart.centerX
|
|||
|
|
const deltaCenterY = center.y - pinchStart.centerY
|
|||
|
|
|
|||
|
|
// 以双指中心为缩放中心:缩放时,双指中心在图片上的相对位置保持不变
|
|||
|
|
// 缩放前双指中心相对于图片中心的偏移:(centerX - w/2 - baseX)
|
|||
|
|
// 缩放后这个偏移应该保持不变,所以:newX = centerX - w/2 - (centerX - w/2 - baseX) * scaleRatio
|
|||
|
|
// newX = centerX - w/2 - centerX * scaleRatio + w/2 * scaleRatio + baseX * scaleRatio
|
|||
|
|
// newX = centerX * (1 - scaleRatio) + baseX * scaleRatio + w/2 * (scaleRatio - 1)
|
|||
|
|
// newX = baseX * scaleRatio + (centerX - w/2) * (1 - scaleRatio)
|
|||
|
|
const newX = pinchStart.baseX * scaleRatio + (pinchStart.centerX - wHalf) * (1 - scaleRatio) + deltaCenterX
|
|||
|
|
const newY = pinchStart.baseY * scaleRatio + (pinchStart.centerY - hHalf) * (1 - scaleRatio) + deltaCenterY
|
|||
|
|
|
|||
|
|
const { maxX, maxY } = getBounds(scale, w, h)
|
|||
|
|
const { x, y } = clampPosition(newX, newY, maxX, maxY)
|
|||
|
|
|
|||
|
|
if (isMultiMode && curIdx !== undefined) {
|
|||
|
|
setPinchState(curIdx, { scale, x, y })
|
|||
|
|
if (scale > 1 && pinchStart.lastCenterX != null) {
|
|||
|
|
const dc = center.x - pinchStart.lastCenterX
|
|||
|
|
pinchStart.lastCenterX = center.x
|
|||
|
|
const dir = tryEdgeSwitch(x, maxX, dc)
|
|||
|
|
if (dir === 'prev') goToSlide(-1)
|
|||
|
|
else if (dir === 'next') goToSlide(1)
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
pinchScale.value = scale
|
|||
|
|
pinchX.value = x
|
|||
|
|
pinchY.value = y
|
|||
|
|
targetPanX = x
|
|||
|
|
targetPanY = y
|
|||
|
|
clampTranslateToBounds()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 处理单指拖动(放大状态) */
|
|||
|
|
function handlePanDrag(t: { clientX: number; clientY: number }, isMultiMode: boolean, curIdx?: number): number {
|
|||
|
|
if (!panStart) return 0
|
|||
|
|
|
|||
|
|
const deltaDx = t.clientX - panStart.lastX
|
|||
|
|
panStart.lastX = t.clientX
|
|||
|
|
panStart.lastY = t.clientY
|
|||
|
|
|
|||
|
|
if (isMultiMode && curIdx !== undefined) {
|
|||
|
|
const curState = getPinchState(curIdx)
|
|||
|
|
if (curState.scale <= 1) return deltaDx
|
|||
|
|
|
|||
|
|
const { width: w, height: h } = windowSize.value
|
|||
|
|
const { maxX, maxY } = getBounds(curState.scale, w, h)
|
|||
|
|
const rawX = panStart.baseX + (t.clientX - panStart.startX)
|
|||
|
|
const rawY = panStart.baseY + (t.clientY - panStart.startY)
|
|||
|
|
const { x, y } = clampPosition(rawX, rawY, maxX, maxY)
|
|||
|
|
|
|||
|
|
setPinchState(curIdx, { ...curState, x, y })
|
|||
|
|
return deltaDx
|
|||
|
|
} else {
|
|||
|
|
const scale = pinchScale.value
|
|||
|
|
if (scale <= 1) return deltaDx
|
|||
|
|
|
|||
|
|
const { maxX, maxY } = getPinchBounds()
|
|||
|
|
const rawX = panStart.baseX + (t.clientX - panStart.startX)
|
|||
|
|
const rawY = panStart.baseY + (t.clientY - panStart.startY)
|
|||
|
|
const { x, y } = clampPosition(rawX, rawY, maxX, maxY)
|
|||
|
|
|
|||
|
|
pinchX.value = x
|
|||
|
|
pinchY.value = y
|
|||
|
|
targetPanX = x
|
|||
|
|
targetPanY = y
|
|||
|
|
return deltaDx
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 处理单指滑动(未放大状态,多图模式) */
|
|||
|
|
function handleSlideDrag(t: { clientX: number; clientY: number }): void {
|
|||
|
|
if (!panStart) return
|
|||
|
|
|
|||
|
|
const dx = t.clientX - panStart.startX
|
|||
|
|
const w = windowSize.value.width
|
|||
|
|
const raw = (panStart.baseDragOffset ?? 0) + dx * SLIDE_DRAG_FACTOR
|
|||
|
|
dragOffsetPx.value = clamp(raw, -w, w)
|
|||
|
|
targetDragOffset = dragOffsetPx.value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetPinch() {
|
|||
|
|
pinchScale.value = 1
|
|||
|
|
pinchX.value = 0
|
|||
|
|
pinchY.value = 0
|
|||
|
|
targetPanX = 0
|
|||
|
|
targetPanY = 0
|
|||
|
|
targetDragOffset = 0
|
|||
|
|
pinchStart = null
|
|||
|
|
panStart = null
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 清空每张图独立的缩放/平移状态,退出预览时调用 */
|
|||
|
|
function resetPinchStateByIndex() {
|
|||
|
|
for (const key of Object.keys(pinchStateByIndex)) delete pinchStateByIndex[Number(key)]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function goToSlide(delta: -1 | 1) {
|
|||
|
|
const n = props.urls?.length || 0
|
|||
|
|
if (n <= 1) return
|
|||
|
|
const oldIdx = currentIndex.value
|
|||
|
|
const nextIdx = delta < 0 ? Math.max(0, oldIdx - 1) : Math.min(n - 1, oldIdx + 1)
|
|||
|
|
if (nextIdx === oldIdx) return
|
|||
|
|
currentIndex.value = nextIdx
|
|||
|
|
setPinchState(oldIdx, { ...getPinchState(oldIdx), x: 0, y: 0 })
|
|||
|
|
slideTransitionOn.value = true
|
|||
|
|
dragOffsetPx.value = 0
|
|||
|
|
targetDragOffset = 0
|
|||
|
|
setTimeout(() => { slideTransitionOn.value = false }, SLIDE_ANIMATION_MS)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 触摸事件处理 ====================
|
|||
|
|
/** 创建单指拖动开始状态 */
|
|||
|
|
function createPanStart(
|
|||
|
|
t: { clientX: number; clientY: number },
|
|||
|
|
baseX: number,
|
|||
|
|
baseY: number,
|
|||
|
|
baseDragOffset?: number
|
|||
|
|
) {
|
|||
|
|
return {
|
|||
|
|
startX: t.clientX,
|
|||
|
|
startY: t.clientY,
|
|||
|
|
baseX,
|
|||
|
|
baseY,
|
|||
|
|
lastX: t.clientX,
|
|||
|
|
lastY: t.clientY,
|
|||
|
|
baseDragOffset
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 单图 */
|
|||
|
|
function ensurePanStart(t: { clientX: number; clientY: number }) {
|
|||
|
|
if (panStart) return
|
|||
|
|
panStart = createPanStart(t, pinchX.value, pinchY.value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onPinchTouchStart(e: any) {
|
|||
|
|
const touches = e.touches
|
|||
|
|
if (touches.length >= 2) {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
panStart = null
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
const dist = Math.max(getDistance(touches), MIN_PINCH_DIST)
|
|||
|
|
const center = getCenter(touches)
|
|||
|
|
pinchStart = {
|
|||
|
|
distance: dist,
|
|||
|
|
centerX: center.x,
|
|||
|
|
centerY: center.y,
|
|||
|
|
baseScale: pinchScale.value,
|
|||
|
|
baseX: pinchX.value,
|
|||
|
|
baseY: pinchY.value
|
|||
|
|
}
|
|||
|
|
} else if (touches.length === 1) {
|
|||
|
|
ensurePanStart(touches[0])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onPinchTouchMove(e: any) {
|
|||
|
|
const touches = e.touches
|
|||
|
|
|
|||
|
|
if (touches.length >= 2 && pinchStart) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
e.stopPropagation()
|
|||
|
|
panStart = null
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
handlePinchZoom(touches, false)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (touches.length === 1 && !pinchStart) {
|
|||
|
|
ensurePanStart(touches[0])
|
|||
|
|
const t = touches[0]
|
|||
|
|
const scale = pinchScale.value
|
|||
|
|
|
|||
|
|
if (scale > 1) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
e.stopPropagation()
|
|||
|
|
const deltaDx = handlePanDrag(t, false)
|
|||
|
|
if (isMulti.value) {
|
|||
|
|
const { maxX } = getPinchBounds()
|
|||
|
|
const dir = tryEdgeSwitch(pinchX.value, maxX, deltaDx)
|
|||
|
|
if (dir === 'prev') goToSlide(-1)
|
|||
|
|
else if (dir === 'next') goToSlide(1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 多图 */
|
|||
|
|
function onSlideTouchStart(e: any) {
|
|||
|
|
const touches = e.touches
|
|||
|
|
if (touches.length >= 2) {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
panStart = null
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
const cur = getPinchState(currentIndex.value)
|
|||
|
|
const dist = Math.max(getDistance(touches), MIN_PINCH_DIST)
|
|||
|
|
const center = getCenter(touches)
|
|||
|
|
pinchStart = {
|
|||
|
|
distance: dist,
|
|||
|
|
centerX: center.x,
|
|||
|
|
centerY: center.y,
|
|||
|
|
baseScale: cur.scale,
|
|||
|
|
baseX: cur.x,
|
|||
|
|
baseY: cur.y,
|
|||
|
|
lastCenterX: center.x
|
|||
|
|
}
|
|||
|
|
} else if (touches.length === 1) {
|
|||
|
|
const cur = getPinchState(currentIndex.value)
|
|||
|
|
panStart = createPanStart(touches[0], cur.x, cur.y, dragOffsetPx.value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSlideTouchMove(e: any) {
|
|||
|
|
const touches = e.touches
|
|||
|
|
const curIdx = currentIndex.value
|
|||
|
|
const curState = getPinchState(curIdx)
|
|||
|
|
|
|||
|
|
if (touches.length >= 2 && pinchStart) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
e.stopPropagation()
|
|||
|
|
panStart = null
|
|||
|
|
handlePinchZoom(touches, true, curIdx)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (touches.length === 1 && !pinchStart) {
|
|||
|
|
const t = touches[0]
|
|||
|
|
if (!panStart) panStart = createPanStart(t, curState.x, curState.y, dragOffsetPx.value)
|
|||
|
|
|
|||
|
|
if (curState.scale > 1) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
e.stopPropagation()
|
|||
|
|
const deltaDx = handlePanDrag(t, true, curIdx)
|
|||
|
|
const { maxX } = getBounds(curState.scale, windowSize.value.width, windowSize.value.height)
|
|||
|
|
const newX = getPinchState(curIdx).x
|
|||
|
|
const dir = tryEdgeSwitch(newX, maxX, deltaDx)
|
|||
|
|
if (dir === 'prev') goToSlide(-1)
|
|||
|
|
else if (dir === 'next') goToSlide(1)
|
|||
|
|
} else {
|
|||
|
|
e.preventDefault()
|
|||
|
|
handleSlideDrag(t)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSlideTouchEnd(e: any) {
|
|||
|
|
if (e.touches.length < 2) pinchStart = null
|
|||
|
|
if (e.touches.length === 0) {
|
|||
|
|
const n = props.urls?.length ?? 0
|
|||
|
|
if (n <= 1) { panStart = null; edgeOverflow = 0; return }
|
|||
|
|
|
|||
|
|
const curIdx = currentIndex.value
|
|||
|
|
const curState = getPinchState(curIdx)
|
|||
|
|
|
|||
|
|
if (curState.scale <= 1) {
|
|||
|
|
targetDragOffset = dragOffsetPx.value
|
|||
|
|
const th = SLIDE_DRAG_THRESHOLD
|
|||
|
|
if (dragOffsetPx.value > th && curIdx > 0) goToSlide(-1)
|
|||
|
|
else if (dragOffsetPx.value < -th && curIdx < n - 1) goToSlide(1)
|
|||
|
|
else {
|
|||
|
|
slideTransitionOn.value = true
|
|||
|
|
dragOffsetPx.value = 0
|
|||
|
|
targetDragOffset = 0
|
|||
|
|
setTimeout(() => { slideTransitionOn.value = false }, SLIDE_ANIMATION_MS)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
panStart = null
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
} else if (e.touches.length === 1 && panStart) {
|
|||
|
|
const cur = getPinchState(currentIndex.value)
|
|||
|
|
panStart = { ...panStart, baseX: cur.x, baseY: cur.y, baseDragOffset: dragOffsetPx.value }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onPinchTouchEnd(e: any) {
|
|||
|
|
if (e.touches.length < 2) pinchStart = null
|
|||
|
|
if (e.touches.length === 0) {
|
|||
|
|
panStart = null
|
|||
|
|
edgeOverflow = 0
|
|||
|
|
targetPanX = pinchX.value
|
|||
|
|
targetPanY = pinchY.value
|
|||
|
|
} else if (e.touches.length === 1) {
|
|||
|
|
ensurePanStart(e.touches[0])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isMulti = computed(() => (props.urls?.length || 0) > 1)
|
|||
|
|
const currentUrl = computed(() => {
|
|||
|
|
const list = props.urls || []
|
|||
|
|
const i = Math.max(0, Math.min(currentIndex.value, list.length - 1))
|
|||
|
|
return list[i] || ''
|
|||
|
|
})
|
|||
|
|
const showSwiper = computed(() => expanded.value && isMulti.value && animPhase.value !== 'exiting')
|
|||
|
|
const showSingleContent = computed(
|
|||
|
|
() => currentUrl.value && (!isMulti.value || !expanded.value || animPhase.value === 'exiting')
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const rootStyle = computed(() => ({ zIndex: props.zIndex }))
|
|||
|
|
|
|||
|
|
// ==================== 动画样式(按状态机统一输出) ====================
|
|||
|
|
const animationStyles = computed(() => {
|
|||
|
|
const phase = animPhase.value
|
|||
|
|
const transOpen = `opacity ${OPEN_BLUR_S}s ${OPEN_ANIMATION_EASE}`
|
|||
|
|
const transOpenFilter = `opacity ${OPEN_BLUR_S}s ${OPEN_ANIMATION_EASE}, filter ${OPEN_BLUR_S}s ${OPEN_ANIMATION_EASE}`
|
|||
|
|
const transExit = `opacity ${EXIT_BLUR_S}s ease-out, filter ${EXIT_BLUR_S}s ease-out`
|
|||
|
|
|
|||
|
|
if (phase === 'exiting') {
|
|||
|
|
return {
|
|||
|
|
root: {
|
|||
|
|
transition: transExit,
|
|||
|
|
filter: `blur(${EXIT_BLUR_MAX}px)`,
|
|||
|
|
opacity: 0,
|
|||
|
|
pointerEvents: 'none' as const
|
|||
|
|
},
|
|||
|
|
backdrop: { transition: `opacity ${EXIT_BLUR_S}s ease-out`, opacity: 0 },
|
|||
|
|
transformWrap: {
|
|||
|
|
transition: transExit,
|
|||
|
|
filter: `blur(${EXIT_BLUR_MAX}px)`,
|
|||
|
|
opacity: 0,
|
|||
|
|
pointerEvents: 'none' as const
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (phase === 'entering' || phase === 'displaying') {
|
|||
|
|
const loaded = isCurrentImageLoaded.value
|
|||
|
|
const hasContent = hasContentToShow.value
|
|||
|
|
const applyTarget = phase === 'displaying' || (phase === 'entering' && enterPaintReady.value)
|
|||
|
|
if (!applyTarget) {
|
|||
|
|
return {
|
|||
|
|
root: { opacity: 0 },
|
|||
|
|
backdrop: { opacity: 0 },
|
|||
|
|
transformWrap: {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 仅目标样式首帧强制模糊,之后随 loaded 变化,既保证有过渡又跟手
|
|||
|
|
const contentBlurred = phase === 'entering' && !enterTargetPainted.value ? true : !loaded
|
|||
|
|
return {
|
|||
|
|
root: hasContent ? { transition: transOpen, opacity: 1 } : {},
|
|||
|
|
backdrop: { transition: transOpen, opacity: 1 },
|
|||
|
|
transformWrap: hasContent
|
|||
|
|
? {
|
|||
|
|
transition: transOpenFilter,
|
|||
|
|
filter: contentBlurred ? `blur(${OPEN_BLUR_MAX}px)` : 'blur(0px)',
|
|||
|
|
opacity: 1
|
|||
|
|
}
|
|||
|
|
: {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { root: {}, backdrop: {}, transformWrap: {} }
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 图片容器基础样式:始终全屏 */
|
|||
|
|
const transformWrapStyle = computed(() => {
|
|||
|
|
const { width: w, height: h } = windowSize.value
|
|||
|
|
return {
|
|||
|
|
left: '0',
|
|||
|
|
top: '0',
|
|||
|
|
width: `${w}px`,
|
|||
|
|
height: `${h}px`,
|
|||
|
|
filter: `blur(${OPEN_BLUR_MAX}px)`,
|
|||
|
|
opacity: 0
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ==================== 图片加载处理 ====================
|
|||
|
|
/** 图片加载成功 */
|
|||
|
|
function onImageLoad(index: number) {
|
|||
|
|
imageLoadStates[index] = 'loaded'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 图片加载失败 */
|
|||
|
|
function onImageError(index: number) {
|
|||
|
|
imageLoadStates[index] = 'error'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 初始化图片加载状态 */
|
|||
|
|
function initImageLoadStates() {
|
|||
|
|
const urls = props.urls || []
|
|||
|
|
urls.forEach((url, index) => {
|
|||
|
|
if (url) {
|
|||
|
|
imageLoadStates[index] = 'loading'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 重置图片加载状态 */
|
|||
|
|
function resetImageLoadStates() {
|
|||
|
|
Object.keys(imageLoadStates).forEach(key => {
|
|||
|
|
delete imageLoadStates[Number(key)]
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function registerBack() {
|
|||
|
|
if (tabRootBackPress) {
|
|||
|
|
unregisterBack = tabRootBackPress.register({
|
|||
|
|
check: () => props.visible,
|
|||
|
|
close
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function unregisterBackHandler() {
|
|||
|
|
if (unregisterBack) {
|
|||
|
|
unregisterBack()
|
|||
|
|
unregisterBack = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => props.visible,
|
|||
|
|
(v) => {
|
|||
|
|
if (v) {
|
|||
|
|
try {
|
|||
|
|
const sys = uni.getSystemInfoSync()
|
|||
|
|
windowSize.value = {
|
|||
|
|
width: sys.windowWidth ?? sys.screenWidth ?? 375,
|
|||
|
|
height: sys.windowHeight ?? sys.screenHeight ?? 667
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
windowSize.value = { width: 375, height: 667 }
|
|||
|
|
}
|
|||
|
|
currentIndex.value = Math.max(0, Math.min(props.current, (props.urls?.length || 1) - 1))
|
|||
|
|
// 初始化图片加载状态
|
|||
|
|
initImageLoadStates()
|
|||
|
|
expanded.value = !props.initialRect || props.initialRect.width <= 0 || props.initialRect.height <= 0
|
|||
|
|
registerBack()
|
|||
|
|
if (props.initialRect && props.initialRect.width > 0 && props.initialRect.height > 0) {
|
|||
|
|
// 先渲染一帧“缩小在点击位置”的状态,再开启动画,避免动画不生效
|
|||
|
|
setTimeout(() => {
|
|||
|
|
expanded.value = true
|
|||
|
|
startEnterPhase()
|
|||
|
|
}, 50)
|
|||
|
|
} else {
|
|||
|
|
expanded.value = true
|
|||
|
|
startEnterPhase()
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
expanded.value = false
|
|||
|
|
animPhase.value = null
|
|||
|
|
enterPaintReady.value = false
|
|||
|
|
enterTargetPainted.value = false
|
|||
|
|
clearAnimTimers()
|
|||
|
|
resetPinch()
|
|||
|
|
resetPinchStateByIndex()
|
|||
|
|
resetImageLoadStates()
|
|||
|
|
clearLoadingIndicatorTimer()
|
|||
|
|
showLoadingOverlay.value = false
|
|||
|
|
dragOffsetPx.value = 0
|
|||
|
|
slideTransitionOn.value = false
|
|||
|
|
unregisterBackHandler()
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ immediate: true }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => props.current,
|
|||
|
|
(v) => {
|
|||
|
|
currentIndex.value = Math.max(0, Math.min(v, (props.urls?.length || 1) - 1))
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/** 切换图片时,如果新图片未加载,初始化加载状态 */
|
|||
|
|
watch(
|
|||
|
|
() => currentIndex.value,
|
|||
|
|
(newIndex) => {
|
|||
|
|
if (!imageLoadStates[newIndex] && props.urls?.[newIndex]) {
|
|||
|
|
imageLoadStates[newIndex] = 'loading'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/** 加载指示器延迟显示:进入 loading 后 0.1s 再显示,离开 loading 立即隐藏 */
|
|||
|
|
const currentLoadState = computed(() => imageLoadStates[currentIndex.value])
|
|||
|
|
watch(
|
|||
|
|
[expanded, currentIndex, currentLoadState],
|
|||
|
|
([exp, , state]) => {
|
|||
|
|
clearLoadingIndicatorTimer()
|
|||
|
|
showLoadingOverlay.value = false
|
|||
|
|
if (exp && state === 'loading') {
|
|||
|
|
loadingIndicatorTimer = setTimeout(() => {
|
|||
|
|
loadingIndicatorTimer = null
|
|||
|
|
if (expanded.value && imageLoadStates[currentIndex.value] === 'loading') {
|
|||
|
|
showLoadingOverlay.value = true
|
|||
|
|
}
|
|||
|
|
}, LOADING_INDICATOR_DELAY_MS)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
clearAnimTimers()
|
|||
|
|
clearLoadingIndicatorTimer()
|
|||
|
|
unregisterBackHandler()
|
|||
|
|
resetPinch()
|
|||
|
|
resetPinchStateByIndex()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
function onOverlayClick() {
|
|||
|
|
requestClose()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 先播退出动画(模糊+透明度渐隐),再通知关闭 */
|
|||
|
|
function requestClose() {
|
|||
|
|
if (animPhase.value === 'exiting') return
|
|||
|
|
if (isMulti.value) {
|
|||
|
|
const s = getPinchState(currentIndex.value)
|
|||
|
|
pinchScale.value = s.scale
|
|||
|
|
pinchX.value = s.x
|
|||
|
|
pinchY.value = s.y
|
|||
|
|
}
|
|||
|
|
startExitPhase(() => {
|
|||
|
|
expanded.value = false
|
|||
|
|
animPhase.value = null
|
|||
|
|
emit('close')
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function close() {
|
|||
|
|
requestClose()
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.iv-root {
|
|||
|
|
position: fixed;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-root--closing {
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-backdrop {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.4);
|
|||
|
|
opacity: 0;
|
|||
|
|
transition: opacity 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-backdrop--expanded {
|
|||
|
|
background: rgba(0, 0, 0, 0.94);
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-backdrop--closing {
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-transform-wrap {
|
|||
|
|
position: fixed;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
z-index: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-pinch-wrap {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
transform-origin: center center;
|
|||
|
|
touch-action: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-slides-view {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-slides-track {
|
|||
|
|
display: flex;
|
|||
|
|
height: 100%;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
will-change: transform;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-slide {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-slide .iv-pinch-wrap {
|
|||
|
|
touch-action: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-slide-img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-dots-wrap {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
z-index: 2;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-dots {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-dots__text {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: rgba(255, 255, 255, 0.9);
|
|||
|
|
background: rgba(0, 0, 0, 0.4);
|
|||
|
|
padding: 8rpx 24rpx;
|
|||
|
|
border-radius: 24rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ==================== 加载指示器(样式参考 PullRefresh 刷新圈) ==================== */
|
|||
|
|
.iv-loading {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: rgba(0, 0, 0, 0.3);
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-loading-spinner {
|
|||
|
|
width: 36rpx;
|
|||
|
|
height: 36rpx;
|
|||
|
|
border: 6rpx solid rgba(255, 255, 255, 0.3);
|
|||
|
|
border-top-color: rgba(255, 255, 255, 0.9);
|
|||
|
|
border-radius: 50%;
|
|||
|
|
animation: iv-spin 0.7s linear infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-error {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: rgba(0, 0, 0, 0.5);
|
|||
|
|
z-index: 10;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.iv-error-text {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: rgba(255, 255, 255, 0.9);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes iv-spin {
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
</style>
|