152 lines
4.6 KiB
JavaScript
152 lines
4.6 KiB
JavaScript
/**
|
||
* useSpotlight - 滚动 spotlight / 边缘渐隐
|
||
*
|
||
* 行为:
|
||
* - opacity 等于「元素在视口中可见的比例」
|
||
* - 元素完全在视口内 → 1
|
||
* - 元素被滚出一半 → 0.5
|
||
* - 元素完全在视口外 → 0
|
||
* - 元素比视口高、且完全覆盖视口 → 1
|
||
*
|
||
* 关键修复(针对 app-plus):
|
||
* - app-plus 的 <scroll-view> 是原生 UIScrollView,WebView 内容本身不滚动。
|
||
* getBoundingClientRect() 永远返回初始 layout 位置(不随原生滚动更新),
|
||
* 所以基于 rAF/setTimeout + getBoundingClientRect 的方案在 app-plus 上完全无效。
|
||
* - 用 uni.createSelectorQuery() 取位置 —— uni-app 官方 API,会桥到原生层,
|
||
* 在 app-plus / H5 / 小程序上都能拿到当前可视位置。
|
||
* - 异步结果用 reqId 防止乱序(连续触发时丢弃旧结果)。
|
||
*
|
||
* 用法(square.vue):
|
||
* const { start, stop } = useSpotlight({ getElements })
|
||
* onMounted(start); onUnmounted(stop)
|
||
* // @scroll 事件也调一次 update(),作为双保险
|
||
*/
|
||
const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现"
|
||
|
||
export function useSpotlight(options = {}) {
|
||
const { getElements = () => [] } = options
|
||
|
||
const seenSet = typeof WeakSet !== "undefined" ? new WeakSet() : new Set()
|
||
|
||
const computeOpacity = (rect, vh) => {
|
||
if (!rect) return 1
|
||
if (rect.bottom <= 0 || rect.top >= vh) return 0
|
||
|
||
if (rect.height >= vh && rect.top <= 0 && rect.bottom >= vh) return 1
|
||
|
||
const visibleTop = Math.max(0, rect.top)
|
||
const visibleBottom = Math.min(vh, rect.bottom)
|
||
const visibleHeight = Math.max(0, visibleBottom - visibleTop)
|
||
const totalHeight = rect.height
|
||
|
||
if (totalHeight <= 0) return 1
|
||
return Math.min(1, visibleHeight / totalHeight)
|
||
}
|
||
|
||
// 给每个 ref 对应的 DOM 节点加 data-spotlight-id,方便 SelectorQuery 结果映射回节点
|
||
const idToNode = new Map()
|
||
let nextId = 1
|
||
const tagNodes = () => {
|
||
const elements = getElements()
|
||
elements.forEach((ref) => {
|
||
if (!ref) return
|
||
const node = ref.$el || ref
|
||
if (!node || node.nodeType !== 1) return
|
||
if (node.dataset && node.dataset.spotlightId) return
|
||
const id = `sl${nextId++}`
|
||
node.setAttribute("data-spotlight-id", id)
|
||
idToNode.set(id, node)
|
||
})
|
||
}
|
||
|
||
// SelectorQuery 异步:连续触发时用 reqId 丢弃旧结果
|
||
let reqId = 0
|
||
|
||
const applyRects = (rects, vh) => {
|
||
if (!rects || !rects.length) return
|
||
rects.forEach((rect) => {
|
||
const id = rect && rect.dataset && rect.dataset.spotlightId
|
||
if (!id) return
|
||
const node = idToNode.get(id) || (typeof document !== "undefined" && document.querySelector(`[data-spotlight-id="${id}"]`))
|
||
if (!node) return
|
||
|
||
const opacity = computeOpacity(rect, vh)
|
||
try {
|
||
node.style.opacity = opacity.toFixed(3)
|
||
} catch (e) {}
|
||
|
||
if (opacity > SEEN_THRESHOLD && !seenSet.has(node)) {
|
||
seenSet.add(node)
|
||
try { node.classList.add("first-seen") } catch (e) {}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 测高度
|
||
const getVH = () => {
|
||
try {
|
||
if (typeof window !== "undefined" && window.innerHeight) return window.innerHeight
|
||
} catch (e) {}
|
||
try {
|
||
const info = uni.getSystemInfoSync()
|
||
return info.windowHeight || info.screenHeight || 667
|
||
} catch (e) {
|
||
return 667
|
||
}
|
||
}
|
||
|
||
const update = () => {
|
||
if (typeof uni === "undefined" || typeof uni.createSelectorQuery !== "function") return
|
||
|
||
tagNodes()
|
||
if (!idToNode.size) return
|
||
|
||
const myReq = ++reqId
|
||
const vh = getVH()
|
||
|
||
try {
|
||
const query = uni.createSelectorQuery()
|
||
query.selectAll("[data-spotlight-id]").boundingClientRect()
|
||
query.exec((res) => {
|
||
if (myReq !== reqId) return // 旧请求,丢弃
|
||
if (!res || !res[0]) return
|
||
applyRects(res[0], vh)
|
||
})
|
||
} catch (e) {
|
||
// SelectorQuery 不可用时不做事
|
||
}
|
||
}
|
||
|
||
// setTimeout 递归轮询:兜底,@scroll 漏触发时仍能跑
|
||
let timerId = null
|
||
const tick = () => {
|
||
update()
|
||
timerId = setTimeout(tick, 50) // 50ms ≈ 20fps,SelectorQuery 开销小但不为零,频率不用太高
|
||
}
|
||
|
||
const start = () => {
|
||
if (timerId) return
|
||
timerId = setTimeout(tick, 50)
|
||
}
|
||
|
||
const stop = () => {
|
||
if (timerId) {
|
||
clearTimeout(timerId)
|
||
timerId = null
|
||
}
|
||
}
|
||
|
||
// 兼容:@scroll 触发时直接调一次(双保险,scroll 事件比 50ms 轮询更密)
|
||
const bindScroll = () => {
|
||
update()
|
||
}
|
||
|
||
return {
|
||
update,
|
||
bindScroll,
|
||
start,
|
||
stop,
|
||
isH5: typeof window !== "undefined" && typeof document !== "undefined",
|
||
}
|
||
}
|