小程序端门店核销记录走马灯效果

This commit is contained in:
2026-08-03 15:12:30 +08:00
parent a7a0a54688
commit 666bcb8633
2 changed files with 196 additions and 135 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReady } from '@tarojs/taro';
@@ -6,7 +6,12 @@ type StoreRedeemMarqueeProps = {
lines: string[];
};
/** 估算单行像素宽(12px 字号,DOM 测量失败时兜底) */
/** 飘动速度 px/s */
const FLY_SPEED = 58;
const MIN_FLY_MS = 2200;
const PAUSE_MIN_MS = 1000;
const PAUSE_MAX_MS = 5000;
function estimateTextWidth(text: string): number {
let w = 0;
for (const ch of text) {
@@ -15,111 +20,204 @@ function estimateTextWidth(text: string): number {
return Math.max(Math.ceil(w), 80);
}
function randomPauseMs() {
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
}
function uid(prefix: string) {
return `${prefix}${Math.random().toString(36).slice(2, 10)}`;
}
function createQuery() {
const page = Taro.getCurrentInstance().page;
return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
}
/**
* 门店核销动态走马灯(H5 + 微信小程序)
*
* - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽)
* - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环
* - 测量失败时用字宽估算,仍能滚动
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
* H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。
*/
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const segment = useMemo(() => {
const joined = lines
.map((s) => String(s || '').trim())
.filter(Boolean)
.join('  ');
return joined ? `${joined}  ` : '';
}, [lines]);
const measureId = useMemo(
() => `sm${Math.random().toString(36).slice(2, 10)}`,
[segment],
const items = useMemo(
() =>
lines
.map((s) => String(s || '').trim())
.filter(Boolean),
[lines],
);
const [segWidth, setSegWidth] = useState(0);
const rootIdRef = useRef(uid('smr'));
const textIdRef = useRef(uid('smt'));
const indexRef = useRef(0);
const boxWidthRef = useRef(0);
const itemsKey = items.join('\n');
const measure = useCallback(() => {
if (!segment) {
setSegWidth(0);
return;
}
const fallback = estimateTextWidth(segment);
Taro.nextTick(() => {
try {
Taro.createSelectorQuery()
.select(`#${measureId}`)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
// weapp 若误测成容器宽,会接近视口;用估算兜底纠正
if (w > 8 && w < fallback * 2.5) {
setSegWidth(Math.ceil(w));
} else {
setSegWidth(fallback);
}
});
} catch {
setSegWidth(fallback);
}
const [displayIndex, setDisplayIndex] = useState(0);
const [offset, setOffset] = useState(9999);
const [ready, setReady] = useState(false);
const measureBox = () =>
new Promise<number>((resolve) => {
Taro.nextTick(() => {
try {
createQuery()
.select(`#${rootIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const box = Number(res?.[0]?.width || 0);
const next = box > 8 ? box : boxWidthRef.current || 300;
boxWidthRef.current = next;
resolve(next);
});
} catch {
resolve(boxWidthRef.current || 300);
}
});
});
const measureText = (text: string) =>
new Promise<number>((resolve) => {
const fallback = estimateTextWidth(text);
Taro.nextTick(() => {
try {
createQuery()
.select(`#${textIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const tw = Number(res?.[0]?.width || 0);
if (tw > 8 && tw < fallback * 3) resolve(Math.ceil(tw));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
}, [segment, measureId]);
useReady(() => {
measure();
void measureBox().then(() => setReady(true));
});
useEffect(() => {
if (!segment) {
setSegWidth(0);
return;
}
setSegWidth(0);
measure();
const t1 = setTimeout(measure, 100);
const t2 = setTimeout(measure, 350);
const t3 = setTimeout(() => {
setSegWidth((prev) => (prev > 0 ? prev : estimateTextWidth(segment)));
}, 600);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, [segment, measure]);
if (!items.length) return;
if (!segment) return null;
let cancelled = false;
const waiters = new Set<ReturnType<typeof setTimeout>>();
let rafId = 0;
let tickTimer: ReturnType<typeof setInterval> | undefined;
const running = segWidth > 0;
const durationSec = Math.max(10, Math.min(90, segWidth / 42));
const trackStyle: CSSProperties | undefined = running
? {
width: `${segWidth * 2}px`,
animationDuration: `${durationSec}s`,
// 微信 / Safari
WebkitAnimationDuration: `${durationSec}s`,
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const id = setTimeout(() => {
waiters.delete(id);
resolve();
}, ms);
waiters.add(id);
});
const clearAnim = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
: undefined;
const segStyle: CSSProperties | undefined = running
? { width: `${segWidth}px` }
: undefined;
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = undefined;
}
};
const fly = (start: number, end: number, durationMs: number) =>
new Promise<void>((resolve) => {
const began = Date.now();
setOffset(start);
const step = () => {
if (cancelled) {
clearAnim();
resolve();
return;
}
const t = Math.min(1, (Date.now() - began) / durationMs);
setOffset(start + (end - start) * t);
if (t >= 1) {
clearAnim();
resolve();
return;
}
if (typeof requestAnimationFrame === 'function') {
rafId = requestAnimationFrame(step);
}
};
clearAnim();
if (typeof requestAnimationFrame === 'function') {
rafId = requestAnimationFrame(step);
} else {
tickTimer = setInterval(step, 32);
}
});
const loop = async () => {
indexRef.current = 0;
setDisplayIndex(0);
await sleep(60);
if (cancelled) return;
while (!cancelled) {
if (!items.length) break;
const idx = indexRef.current % items.length;
const text = items[idx];
setDisplayIndex(idx);
setOffset(9999);
await sleep(48);
if (cancelled) break;
const box = await measureBox();
const textW = await measureText(text);
if (cancelled) break;
const start = box;
const end = -textW;
const distance = start - end;
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
await fly(start, end, durationMs);
if (cancelled) break;
// 飞出后随机停留 1~5 秒,再播下一条
await sleep(randomPauseMs());
if (cancelled) break;
indexRef.current = (idx + 1) % items.length;
}
};
void loop();
return () => {
cancelled = true;
clearAnim();
waiters.forEach(clearTimeout);
waiters.clear();
};
}, [itemsKey, items]);
if (!items.length) return null;
const current = items[displayIndex] || items[0];
const textStyle: CSSProperties = {
transform: `translateX(${offset}px)`,
WebkitTransform: `translateX(${offset}px)`,
opacity: ready ? 1 : 0,
};
return (
<View className="store-detail-marquee">
<View
className={`store-detail-marquee-track${running ? ' is-running' : ''}`}
style={trackStyle}
>
<Text
id={measureId}
className="store-detail-marquee-seg store-detail-marquee-text"
style={segStyle}
>
{segment}
</Text>
<Text className="store-detail-marquee-seg store-detail-marquee-text" style={segStyle}>
{segment}
</Text>
</View>
<View id={rootIdRef.current} className="store-detail-marquee">
<Text id={textIdRef.current} className="store-detail-marquee-text" style={textStyle}>
{current}
</Text>
</View>
);
}
+8 -45
View File
@@ -136,60 +136,23 @@
overflow: hidden;
height: 40px;
box-sizing: border-box;
}
.store-detail-marquee-track {
position: relative;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
height: 20px;
transform: translateX(0);
}
.store-detail-marquee-track.is-running {
animation-name: store-detail-marquee-scroll;
animation-timing-function: linear;
animation-iteration-count: infinite;
-webkit-animation-name: store-detail-marquee-scroll;
-webkit-animation-timing-function: linear;
-webkit-animation-iteration-count: infinite;
}
.store-detail-marquee-seg {
display: inline-block;
flex: none;
box-sizing: content-box;
white-space: nowrap;
overflow: hidden;
vertical-align: top;
}
.store-detail-marquee-text {
position: absolute;
left: 0;
top: 50%;
margin-top: -10px;
display: inline-block;
white-space: nowrap;
font-size: 12px;
line-height: 20px;
color: #a61d24;
}
@keyframes store-detail-marquee-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@-webkit-keyframes store-detail-marquee-scroll {
0% {
-webkit-transform: translateX(0);
transform: translateX(0);
}
100% {
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
}
will-change: transform;
pointer-events: none;
}
.store-detail-section {