小程序端门店核销记录走马灯效果
This commit is contained in:
@@ -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 { View, Text } from '@tarojs/components';
|
||||||
import Taro, { useReady } from '@tarojs/taro';
|
import Taro, { useReady } from '@tarojs/taro';
|
||||||
|
|
||||||
@@ -6,7 +6,12 @@ type StoreRedeemMarqueeProps = {
|
|||||||
lines: string[];
|
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 {
|
function estimateTextWidth(text: string): number {
|
||||||
let w = 0;
|
let w = 0;
|
||||||
for (const ch of text) {
|
for (const ch of text) {
|
||||||
@@ -15,111 +20,204 @@ function estimateTextWidth(text: string): number {
|
|||||||
return Math.max(Math.ceil(w), 80);
|
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 + 微信小程序)
|
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
|
||||||
*
|
* H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。
|
||||||
* - 用 Text 测自然内容宽(weapp 的 View 默认撑满父级,不能用来测宽)
|
|
||||||
* - 两段显式同宽 + 轨道宽 = 2×段宽,CSS translateX(-50%) 无缝循环
|
|
||||||
* - 测量失败时用字宽估算,仍能滚动
|
|
||||||
*/
|
*/
|
||||||
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||||
const segment = useMemo(() => {
|
const items = useMemo(
|
||||||
const joined = lines
|
() =>
|
||||||
|
lines
|
||||||
.map((s) => String(s || '').trim())
|
.map((s) => String(s || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean),
|
||||||
.join(' ');
|
[lines],
|
||||||
return joined ? `${joined} ` : '';
|
|
||||||
}, [lines]);
|
|
||||||
|
|
||||||
const measureId = useMemo(
|
|
||||||
() => `sm${Math.random().toString(36).slice(2, 10)}`,
|
|
||||||
[segment],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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(() => {
|
const [displayIndex, setDisplayIndex] = useState(0);
|
||||||
if (!segment) {
|
const [offset, setOffset] = useState(9999);
|
||||||
setSegWidth(0);
|
const [ready, setReady] = useState(false);
|
||||||
return;
|
|
||||||
}
|
const measureBox = () =>
|
||||||
const fallback = estimateTextWidth(segment);
|
new Promise<number>((resolve) => {
|
||||||
Taro.nextTick(() => {
|
Taro.nextTick(() => {
|
||||||
try {
|
try {
|
||||||
Taro.createSelectorQuery()
|
createQuery()
|
||||||
.select(`#${measureId}`)
|
.select(`#${rootIdRef.current}`)
|
||||||
.boundingClientRect()
|
.boundingClientRect()
|
||||||
.exec((res) => {
|
.exec((res) => {
|
||||||
const w = Number(res?.[0]?.width || 0);
|
const box = Number(res?.[0]?.width || 0);
|
||||||
// weapp 若误测成容器宽,会接近视口;用估算兜底纠正
|
const next = box > 8 ? box : boxWidthRef.current || 300;
|
||||||
if (w > 8 && w < fallback * 2.5) {
|
boxWidthRef.current = next;
|
||||||
setSegWidth(Math.ceil(w));
|
resolve(next);
|
||||||
} else {
|
|
||||||
setSegWidth(fallback);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setSegWidth(fallback);
|
resolve(boxWidthRef.current || 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [segment, measureId]);
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
useReady(() => {
|
useReady(() => {
|
||||||
measure();
|
void measureBox().then(() => setReady(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!segment) {
|
if (!items.length) return;
|
||||||
setSegWidth(0);
|
|
||||||
|
let cancelled = false;
|
||||||
|
const waiters = new Set<ReturnType<typeof setTimeout>>();
|
||||||
|
let rafId = 0;
|
||||||
|
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setSegWidth(0);
|
const t = Math.min(1, (Date.now() - began) / durationMs);
|
||||||
measure();
|
setOffset(start + (end - start) * t);
|
||||||
const t1 = setTimeout(measure, 100);
|
if (t >= 1) {
|
||||||
const t2 = setTimeout(measure, 350);
|
clearAnim();
|
||||||
const t3 = setTimeout(() => {
|
resolve();
|
||||||
setSegWidth((prev) => (prev > 0 ? prev : estimateTextWidth(segment)));
|
return;
|
||||||
}, 600);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t1);
|
|
||||||
clearTimeout(t2);
|
|
||||||
clearTimeout(t3);
|
|
||||||
};
|
|
||||||
}, [segment, measure]);
|
|
||||||
|
|
||||||
if (!segment) return null;
|
|
||||||
|
|
||||||
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`,
|
|
||||||
}
|
}
|
||||||
: undefined;
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
const segStyle: CSSProperties | undefined = running
|
rafId = requestAnimationFrame(step);
|
||||||
? { width: `${segWidth}px` }
|
}
|
||||||
: undefined;
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<View className="store-detail-marquee">
|
<View id={rootIdRef.current} className="store-detail-marquee">
|
||||||
<View
|
<Text id={textIdRef.current} className="store-detail-marquee-text" style={textStyle}>
|
||||||
className={`store-detail-marquee-track${running ? ' is-running' : ''}`}
|
{current}
|
||||||
style={trackStyle}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
id={measureId}
|
|
||||||
className="store-detail-marquee-seg store-detail-marquee-text"
|
|
||||||
style={segStyle}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-detail-marquee-seg store-detail-marquee-text" style={segStyle}>
|
|
||||||
{segment}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,60 +136,23 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
position: relative;
|
||||||
|
|
||||||
.store-detail-marquee-track {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
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 {
|
.store-detail-marquee-text {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -10px;
|
||||||
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: #a61d24;
|
color: #a61d24;
|
||||||
}
|
will-change: transform;
|
||||||
|
pointer-events: none;
|
||||||
@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%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-section {
|
.store-detail-section {
|
||||||
|
|||||||
Reference in New Issue
Block a user