From 38045073b016cf97963dc13d0d2c4f7fff4f706e Mon Sep 17 00:00:00 2001 From: obroccolio Date: Sat, 8 Aug 2026 02:03:03 +0800 Subject: [PATCH] fix: correct home mask threshold after early scroll --- src/pages/index/index.scss | 1 - src/pages/index/index.tsx | 36 ++++++++++++++++++++++-------------- src/utils/homeTopMask.js | 13 +++++++++++++ tools/homeTopMask.test.mjs | 31 ++++++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/pages/index/index.scss b/src/pages/index/index.scss index c12ef4c..6bb73b5 100644 --- a/src/pages/index/index.scss +++ b/src/pages/index/index.scss @@ -261,7 +261,6 @@ z-index: 40; pointer-events: none; transform: translateZ(0); - will-change: opacity; } .theme-light .home-top-mask { diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 5afef9f..d0d1ff8 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -1,6 +1,6 @@ import { View, Text, Image, Input, Button, Swiper, SwiperItem } from '@tarojs/components' import Taro from '@tarojs/taro' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import './index.scss' import { CATEGORIES } from '../../utils/productConfig' import { assetUrl } from '../../utils/asset' @@ -8,7 +8,7 @@ import { useThemeContext } from '../../context/ThemeContext' import { useSafeArea } from '../../hooks/useSafeArea' import { useStatusBar } from '../../hooks/useStatusBar' import ThemedPageMeta from '../../components/ThemedPageMeta' -import { getTopMaskProgress } from '../../utils/homeTopMask' +import { getTopMaskProgress, getTopMaskStartY } from '../../utils/homeTopMask' // 成品展示配图(从 img 里取对应实物图) const SHOWCASE_LIST = [ @@ -72,11 +72,15 @@ export default function Index() { const [searchKey, setSearchKey] = useState('') 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 | null>(null) const latestScrollTopRef = useRef(0) - const applyTopMaskProgress = (scrollTop: number) => { + const applyTopMaskProgress = useCallback((scrollTop: number) => { latestScrollTopRef.current = scrollTop if (scrollFrameRef.current !== null) { return @@ -87,29 +91,33 @@ export default function Index() { const { startY, rangeY } = titleMetricsRef.current setTopMaskOpacity(getTopMaskProgress({ scrollTop: latestScrollTopRef.current, startY, rangeY })) }, 16) - } + }, []) useEffect(() => { let cancelled = false let measureTimer: ReturnType | null = null + titleMetricsRef.current = fallbackTitleMetrics + + type HomeHeaderRect = Taro.NodesRef.BoundingClientRectCallbackResult + const measureTitle = () => { if (cancelled) return Taro.createSelectorQuery() .select('.home-header') - .boundingClientRect((rect: any) => { + .boundingClientRect((rect: HomeHeaderRect | HomeHeaderRect[]) => { if (cancelled) return - if (!rect || typeof rect.bottom !== 'number' || typeof rect.height !== 'number') { - const fallbackRange = Math.max(48, safe.headerPaddingTop * 0.45) - titleMetricsRef.current = { - startY: Math.max(0, safe.headerPaddingTop - safe.statusBarHeight), - rangeY: fallbackRange - } + if (!rect || Array.isArray(rect) || typeof rect.bottom !== 'number' || typeof rect.height !== 'number') { + titleMetricsRef.current = fallbackTitleMetrics applyTopMaskProgress(latestScrollTopRef.current) 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) titleMetricsRef.current = { startY, rangeY } applyTopMaskProgress(latestScrollTopRef.current) @@ -132,7 +140,7 @@ export default function Index() { } scrollFrameRef.current = null } - }, [safe.headerPaddingTop, safe.statusBarHeight]) + }, [applyTopMaskProgress, fallbackTitleMetrics]) Taro.usePageScroll((e) => { applyTopMaskProgress(e.scrollTop) diff --git a/src/utils/homeTopMask.js b/src/utils/homeTopMask.js index 39e0273..441a05d 100644 --- a/src/utils/homeTopMask.js +++ b/src/utils/homeTopMask.js @@ -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 }) { const safeScrollTop = Number.isFinite(scrollTop) ? scrollTop : 0 const start = Math.max(0, Number.isFinite(startY) ? startY : 0) diff --git a/tools/homeTopMask.test.mjs b/tools/homeTopMask.test.mjs index 7873cf8..8111c8d 100644 --- a/tools/homeTopMask.test.mjs +++ b/tools/homeTopMask.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' 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', () => { 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: 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 + ) +})