353 lines
7.6 KiB
Vue
353 lines
7.6 KiB
Vue
<template>
|
||
<view class="card-container">
|
||
<view
|
||
:id="cardId"
|
||
class="card-frame"
|
||
@touchstart.stop="onFrameTouchStart"
|
||
@touchmove.stop.prevent="onFrameTouchMove"
|
||
@touchend.stop="onFrameTouchEnd"
|
||
@touchcancel.stop="onFrameTouchEnd"
|
||
>
|
||
<view class="card-body" :style="cardRotateStyle">
|
||
<view
|
||
v-for="layer in layers"
|
||
:key="layer.id"
|
||
class="card-layer"
|
||
:style="getLayerStyle(layer)"
|
||
>
|
||
<image
|
||
v-if="layer.src"
|
||
class="card-layer-img"
|
||
:src="layer.src"
|
||
mode="aspectFill"
|
||
/>
|
||
<template v-else>
|
||
<view
|
||
v-for="(dot, i) in layer.dots || []"
|
||
:key="i"
|
||
class="card-layer-dot"
|
||
:style="getDotStyle(dot)"
|
||
/>
|
||
</template>
|
||
</view>
|
||
|
||
<view class="lenticular-shimmer" :style="shimmerStyle" />
|
||
<view class="lenticular-tint" />
|
||
<view class="glass-rim" />
|
||
<view class="vignette" />
|
||
</view>
|
||
|
||
<view v-if="showHint && tiltHintText" class="tilt-hint">
|
||
<text class="tilt-hint-icon">↻</text>
|
||
<text class="tilt-hint-text">{{ tiltHintText }}</text>
|
||
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览(倾斜或拖动体验光栅效果)</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, getCurrentInstance, onMounted, ref, watch } from 'vue'
|
||
|
||
const props = defineProps({
|
||
layers: { type: Array, required: true },
|
||
transforms: { type: Object, required: true },
|
||
gyroSource: { type: String, default: 'simulation' },
|
||
approximatePreview: { type: Boolean, default: true },
|
||
skipBuiltInTouch: { type: Boolean, default: false },
|
||
tiltHintText: { type: String, default: '倾斜手机预览' },
|
||
})
|
||
|
||
const emit = defineEmits(['simulate'])
|
||
|
||
const showHint = ref(true)
|
||
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
|
||
const cardRect = ref(null)
|
||
/** Vue3 + uni:createSelectorQuery().in() 必须传组件 public 实例(proxy),传 internal instance 会运行时报错导致整页白屏 */
|
||
const pageProxy = getCurrentInstance()?.proxy
|
||
|
||
const MOTION_HIDE_PX = 0.45
|
||
|
||
function shouldHideTiltHint() {
|
||
if (
|
||
props.gyroSource === 'accelerometer' ||
|
||
props.gyroSource === 'gyroscope' ||
|
||
props.gyroSource === 'orientation'
|
||
) {
|
||
return true
|
||
}
|
||
return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX)
|
||
}
|
||
|
||
watch(
|
||
() => props.gyroSource,
|
||
() => {
|
||
if (shouldHideTiltHint()) showHint.value = false
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
() => props.transforms,
|
||
() => {
|
||
if (shouldHideTiltHint()) showHint.value = false
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
const tiltVisualX = computed(() => {
|
||
let num = 0
|
||
let den = 0
|
||
for (const layer of props.layers) {
|
||
const t = props.transforms[layer.id]
|
||
if (!t) continue
|
||
const w = Math.max(0.06, t.opacity)
|
||
num += t.x * w
|
||
den += w
|
||
}
|
||
return den > 1e-6 ? num / den : 0
|
||
})
|
||
|
||
const cardRotateStyle = computed(() => {
|
||
const x = tiltVisualX.value
|
||
const rotateY = (x / 120) * 12
|
||
return {
|
||
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
|
||
}
|
||
})
|
||
|
||
const shimmerStyle = computed(() => {
|
||
const x = tiltVisualX.value
|
||
const angle = 135 + (x / 120) * 60
|
||
return {
|
||
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,0.10) 50%, transparent 70%)`,
|
||
}
|
||
})
|
||
|
||
function getLayerStyle(layer) {
|
||
const t = props.transforms[layer.id]
|
||
const baseOpacity = t ? t.opacity : layer.opacity
|
||
const x = t ? t.x : 0
|
||
// 不在行内样式写 mix-blend-mode:部分小程序 / WebView 对非 normal 支持差,可能引发渲染异常
|
||
return {
|
||
opacity: baseOpacity,
|
||
transform: `translate3d(${x}px, 0, 0)`,
|
||
background: layer.background || 'transparent',
|
||
}
|
||
}
|
||
|
||
function getDotStyle(dot) {
|
||
const color = dot.color || '#dae2fd'
|
||
return {
|
||
left: `${dot.x}%`,
|
||
top: `${dot.y}%`,
|
||
width: `${dot.r * 2}px`,
|
||
height: `${dot.r * 2}px`,
|
||
background: color,
|
||
opacity: dot.opacity != null ? dot.opacity : 1,
|
||
boxShadow: `0 0 ${dot.r * 3}px ${color}`,
|
||
}
|
||
}
|
||
|
||
function refreshRect() {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
|
||
query
|
||
.select(`#${cardId}`)
|
||
.boundingClientRect((rect) => {
|
||
if (rect && rect.width) {
|
||
cardRect.value = {
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
}
|
||
}
|
||
resolve()
|
||
})
|
||
.exec()
|
||
} catch (e) {
|
||
console.warn('[LenticularCard] refreshRect failed', e)
|
||
resolve()
|
||
}
|
||
})
|
||
}
|
||
|
||
function dispatch(clientX, clientY) {
|
||
const rect = cardRect.value
|
||
if (!rect || !rect.width || !rect.height) {
|
||
void refreshRect()
|
||
return
|
||
}
|
||
const cx = rect.left + rect.width / 2
|
||
const cy = rect.top + rect.height / 2
|
||
const nx = ((clientX - cx) / (rect.width / 2)) * 1.1
|
||
const ny = ((clientY - cy) / (rect.height / 2)) * 1.1
|
||
emit(
|
||
'simulate',
|
||
Math.max(-1, Math.min(1, nx)),
|
||
Math.max(-1, Math.min(1, ny))
|
||
)
|
||
}
|
||
|
||
function onTouchMove(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
const touch = e.touches && e.touches[0] ? e.touches[0] : e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null
|
||
if (!touch) return
|
||
dispatch(touch.clientX, touch.clientY)
|
||
}
|
||
|
||
function onFrameTouchStart(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
onTouchMove(e)
|
||
}
|
||
|
||
function onFrameTouchMove(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
onTouchMove(e)
|
||
}
|
||
|
||
function onFrameTouchEnd() {
|
||
if (props.skipBuiltInTouch) return
|
||
emit('simulate', 0, 0)
|
||
}
|
||
|
||
onMounted(() => {
|
||
setTimeout(() => {
|
||
void refreshRect()
|
||
}, 0)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
perspective: 1000px;
|
||
}
|
||
|
||
.card-frame {
|
||
position: relative;
|
||
height: 100%;
|
||
max-height: 100%;
|
||
aspect-ratio: 3 / 4;
|
||
width: auto;
|
||
max-width: min(420px, 94vw);
|
||
}
|
||
|
||
.card-body {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 24px;
|
||
overflow: hidden;
|
||
transform-style: preserve-3d;
|
||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
|
||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||
background-color: #060e20;
|
||
will-change: transform;
|
||
}
|
||
|
||
.card-layer {
|
||
position: absolute;
|
||
top: -6%;
|
||
left: -6%;
|
||
width: 112%;
|
||
height: 112%;
|
||
will-change: transform, opacity;
|
||
background-size: cover;
|
||
background-position: center;
|
||
}
|
||
|
||
.card-layer-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.card-layer-dot {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
.lenticular-shimmer {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.lenticular-tint {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(221, 183, 255, 0.1) 0%,
|
||
transparent 35%,
|
||
transparent 65%,
|
||
rgba(76, 215, 246, 0.1) 100%
|
||
);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.glass-rim {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 24px;
|
||
border-top: 1px solid rgba(221, 183, 255, 0.42);
|
||
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.05);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.vignette {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(to top, rgba(6, 14, 32, 0.82), transparent 50%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tilt-hint {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
background: rgba(45, 52, 73, 0.6);
|
||
backdrop-filter: blur(12px);
|
||
padding: 14px 22px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tilt-hint-icon {
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
color: #ddb7ff;
|
||
}
|
||
|
||
.tilt-hint-text {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.08em;
|
||
color: #dae2fd;
|
||
}
|
||
|
||
.tilt-hint-sub {
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.04em;
|
||
color: rgba(207, 194, 214, 0.75);
|
||
text-align: center;
|
||
max-width: 200px;
|
||
line-height: 1.35;
|
||
}
|
||
</style>
|