fix: correct home mask threshold after early scroll

This commit is contained in:
2026-08-08 02:03:03 +08:00
parent 30eeb8611a
commit 38045073b0
4 changed files with 65 additions and 16 deletions
-1
View File
@@ -261,7 +261,6 @@
z-index: 40; z-index: 40;
pointer-events: none; pointer-events: none;
transform: translateZ(0); transform: translateZ(0);
will-change: opacity;
} }
.theme-light .home-top-mask { .theme-light .home-top-mask {
+22 -14
View File
@@ -1,6 +1,6 @@
import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components' import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import './index.scss' import './index.scss'
import { CATEGORIES } from '../../utils/productConfig' import { CATEGORIES } from '../../utils/productConfig'
import { assetUrl } from '../../utils/asset' import { assetUrl } from '../../utils/asset'
@@ -8,7 +8,7 @@ import { useThemeContext } from '../../context/ThemeContext'
import { useSafeArea } from '../../hooks/useSafeArea' import { useSafeArea } from '../../hooks/useSafeArea'
import { useStatusBar } from '../../hooks/useStatusBar' import { useStatusBar } from '../../hooks/useStatusBar'
import ThemedPageMeta from '../../components/ThemedPageMeta' import ThemedPageMeta from '../../components/ThemedPageMeta'
import { getTopMaskProgress } from '../../utils/homeTopMask' import { getTopMaskProgress, getTopMaskStartY } from '../../utils/homeTopMask'
// 成品展示配图(从 img 里取对应实物图) // 成品展示配图(从 img 里取对应实物图)
const SHOWCASE_LIST = [ const SHOWCASE_LIST = [
@@ -72,11 +72,15 @@ export default function Index() {
const [searchKey, setSearchKey] = useState('') const [searchKey, setSearchKey] = useState('')
const [topMaskOpacity, setTopMaskOpacity] = useState(0) const [topMaskOpacity, setTopMaskOpacity] = useState(0)
const titleMetricsRef = useRef({ startY: 0, rangeY: 48 }) const fallbackTitleMetrics = useMemo(() => ({
startY: Math.max(0, safe.headerPaddingTop - safe.statusBarHeight),
rangeY: Math.max(48, safe.headerPaddingTop * 0.45)
}), [safe.headerPaddingTop, safe.statusBarHeight])
const titleMetricsRef = useRef(fallbackTitleMetrics)
const scrollFrameRef = useRef<ReturnType<typeof setTimeout> | null>(null) const scrollFrameRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestScrollTopRef = useRef(0) const latestScrollTopRef = useRef(0)
const applyTopMaskProgress = (scrollTop: number) => { const applyTopMaskProgress = useCallback((scrollTop: number) => {
latestScrollTopRef.current = scrollTop latestScrollTopRef.current = scrollTop
if (scrollFrameRef.current !== null) { if (scrollFrameRef.current !== null) {
return return
@@ -87,29 +91,33 @@ export default function Index() {
const { startY, rangeY } = titleMetricsRef.current const { startY, rangeY } = titleMetricsRef.current
setTopMaskOpacity(getTopMaskProgress({ scrollTop: latestScrollTopRef.current, startY, rangeY })) setTopMaskOpacity(getTopMaskProgress({ scrollTop: latestScrollTopRef.current, startY, rangeY }))
}, 16) }, 16)
} }, [])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let measureTimer: ReturnType<typeof setTimeout> | null = null let measureTimer: ReturnType<typeof setTimeout> | null = null
titleMetricsRef.current = fallbackTitleMetrics
type HomeHeaderRect = Taro.NodesRef.BoundingClientRectCallbackResult
const measureTitle = () => { const measureTitle = () => {
if (cancelled) return if (cancelled) return
Taro.createSelectorQuery() Taro.createSelectorQuery()
.select('.home-header') .select('.home-header')
.boundingClientRect((rect: any) => { .boundingClientRect((rect: HomeHeaderRect | HomeHeaderRect[]) => {
if (cancelled) return if (cancelled) return
if (!rect || typeof rect.bottom !== 'number' || typeof rect.height !== 'number') { if (!rect || Array.isArray(rect) || typeof rect.bottom !== 'number' || typeof rect.height !== 'number') {
const fallbackRange = Math.max(48, safe.headerPaddingTop * 0.45) titleMetricsRef.current = fallbackTitleMetrics
titleMetricsRef.current = {
startY: Math.max(0, safe.headerPaddingTop - safe.statusBarHeight),
rangeY: fallbackRange
}
applyTopMaskProgress(latestScrollTopRef.current) applyTopMaskProgress(latestScrollTopRef.current)
return return
} }
const startY = Math.max(0, rect.bottom - safe.statusBarHeight - 8) const startY = getTopMaskStartY({
viewportBottom: rect.bottom,
scrollTop: latestScrollTopRef.current,
statusBarHeight: safe.statusBarHeight
})
const rangeY = Math.max(32, rect.height * 0.7) const rangeY = Math.max(32, rect.height * 0.7)
titleMetricsRef.current = { startY, rangeY } titleMetricsRef.current = { startY, rangeY }
applyTopMaskProgress(latestScrollTopRef.current) applyTopMaskProgress(latestScrollTopRef.current)
@@ -132,7 +140,7 @@ export default function Index() {
} }
scrollFrameRef.current = null scrollFrameRef.current = null
} }
}, [safe.headerPaddingTop, safe.statusBarHeight]) }, [applyTopMaskProgress, fallbackTitleMetrics])
Taro.usePageScroll((e) => { Taro.usePageScroll((e) => {
applyTopMaskProgress(e.scrollTop) applyTopMaskProgress(e.scrollTop)
+13
View File
@@ -1,3 +1,16 @@
/**
* Convert a viewport-relative header bottom into a document-relative mask
* threshold. Selector queries report coordinates relative to the current
* viewport, so the scroll offset at measurement time must be added back.
*/
export function getTopMaskStartY({ viewportBottom, scrollTop, statusBarHeight }) {
const safeViewportBottom = Number.isFinite(viewportBottom) ? viewportBottom : 0
const safeScrollTop = Number.isFinite(scrollTop) ? scrollTop : 0
const safeStatusBarHeight = Number.isFinite(statusBarHeight) ? statusBarHeight : 0
return Math.max(0, safeViewportBottom + safeScrollTop - safeStatusBarHeight - 8)
}
export function getTopMaskProgress({ scrollTop, startY, rangeY }) { export function getTopMaskProgress({ scrollTop, startY, rangeY }) {
const safeScrollTop = Number.isFinite(scrollTop) ? scrollTop : 0 const safeScrollTop = Number.isFinite(scrollTop) ? scrollTop : 0
const start = Math.max(0, Number.isFinite(startY) ? startY : 0) const start = Math.max(0, Number.isFinite(startY) ? startY : 0)
+30 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { getTopMaskProgress } from '../src/utils/homeTopMask.js' import { getTopMaskProgress, getTopMaskStartY } from '../src/utils/homeTopMask.js'
test('returns 0 before the title reaches the status-bar area', () => { test('returns 0 before the title reaches the status-bar area', () => {
assert.equal( assert.equal(
@@ -28,3 +28,32 @@ test('clamps invalid values into a stable progress range', () => {
assert.equal(getTopMaskProgress({ scrollTop: 90, startY: 80, rangeY: 0 }), 1) assert.equal(getTopMaskProgress({ scrollTop: 90, startY: 80, rangeY: 0 }), 1)
assert.equal(getTopMaskProgress({ scrollTop: Number.NaN, startY: 80, rangeY: 40 }), 0) assert.equal(getTopMaskProgress({ scrollTop: Number.NaN, startY: 80, rangeY: 40 }), 0)
}) })
test('converts a viewport-relative title bottom to a document-relative start Y', () => {
assert.equal(
getTopMaskStartY({ viewportBottom: 148, scrollTop: 0, statusBarHeight: 44 }),
96
)
// The same header, measured after the user has already scrolled, must keep
// the same document-relative threshold instead of collapsing to 0.
assert.equal(
getTopMaskStartY({ viewportBottom: 48, scrollTop: 100, statusBarHeight: 44 }),
96
)
})
test('clamps the document-relative start Y at zero', () => {
assert.equal(
getTopMaskStartY({ viewportBottom: 20, scrollTop: 0, statusBarHeight: 44 }),
0
)
})
test('treats non-finite threshold inputs as zero without producing NaN', () => {
assert.equal(
getTopMaskStartY({ viewportBottom: Number.NaN, scrollTop: 80, statusBarHeight: 44 }),
28
)
})