新增SeamlessScroll无缝滚动组件

master
LiuJiaNan 2025-12-31 10:20:47 +08:00
parent 2746cca0c3
commit 5e6f280dfb
2 changed files with 567 additions and 0 deletions

View File

@ -0,0 +1,51 @@
import type { ForwardRefExoticComponent, RefAttributes } from "react";
export interface SeamlessScrollProps {
/** 是否开启自动滚动,默认 true */
value?: boolean;
/** 原始数据列表 */
list: unknown[];
/** 步进速度step 需是单步大小的约数,值越大滚动的越快,默认 1 */
step?: number;
/** 开启滚动的数据量,默认 3 */
limitScrollNum?: number;
/** 是否开启鼠标悬停,默认 true */
hover?: boolean;
/** 控制滚动方向,默认 up */
direction?: "up" | "down" | "left" | "right";
/** 单步运动停止的高度,默认 0 */
singleHeight?: number;
/** 单步运动停止的宽度,默认 0 */
singleWidth?: number;
/** 单步停止等待时间,默认 1000ms */
singleWaitTime?: number;
/** 是否开启 rem 度量,默认 false */
isRemUnit?: boolean;
/** 开启数据更新监听,默认 true */
isWatch?: boolean;
/** 动画时间,默认 0 */
delay?: number;
/** 动画方式,默认 ease-in */
ease?: string | { x1: number; y1: number; x2: number; y2: number };
/** 动画循环次数,-1 表示一直动画,默认 -1 */
count?: number;
/** 拷贝几份滚动列表,默认 1 */
copyNum?: number;
/** 开启鼠标悬停时支持滚轮滚动,默认 false */
wheel?: boolean;
/** 启用单行滚动,默认 false */
singleLine?: boolean;
/** 自定义类名 */
className?: string;
}
export interface SeamlessScrollRef {
reset: () => void;
}
/**
*
*/
declare const SeamlessScroll: ForwardRefExoticComponent<SeamlessScrollProps & RefAttributes<SeamlessScrollRef>>;
export default SeamlessScroll;

View File

@ -0,0 +1,516 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { throttle } from "throttle-debounce";
/**
* 无缝滚动组件
*/
const SeamlessScroll = forwardRef((props, ref) => {
const {
value = true,
list = [],
step = 1,
limitScrollNum = 3,
hover = true,
direction = "up",
singleHeight = 0,
singleWidth = 0,
singleWaitTime = 1000,
isRemUnit = false,
isWatch = true,
delay = 0,
ease = "ease-in",
count = -1,
copyNum = 1,
wheel = false,
singleLine = false,
children,
className,
} = props;
// ==================== DOM Refs ====================
const scrollRef = useRef(null); // 最外层容器 ref
const slotListRef = useRef(null); // 原始列表 ref用于计算宽度
const realBoxRef = useRef(null); // 实际滚动的容器 ref包含原始+拷贝内容)
// ==================== Animation Refs ====================
const reqFrame = useRef(null); // requestAnimationFrame 的 ID用于取消动画
const singleWaitTimeout = useRef(null); // 单步滚动等待定时器的 ID
const isHoverRef = useRef(false); // 使用 ref 同步跟踪 hover 状态,避免 React state 异步更新问题
// ==================== States ====================
const [realBoxWidth, setRealBoxWidth] = useState(0); // 滚动容器的实际宽度
const [realBoxHeight, setRealBoxHeight] = useState(0); // 滚动容器的实际高度(包含原始+拷贝)
const [xPos, setXPos] = useState(0); // 当前 X 轴偏移量
const [yPos, setYPos] = useState(0); // 当前 Y 轴偏移量
const [_count, setCount] = useState(0); // 当前已滚动的循环次数
// ==================== Computed Values ====================
const isScroll = list.length >= limitScrollNum; // 是否需要滚动(列表长度超过限制)
const realBoxStyle = {
width: realBoxWidth ? `${realBoxWidth}px` : "auto",
transform: `translate(${xPos}px,${yPos}px)`,
transition: `all ${typeof ease === "string" ? ease : `cubic-bezier(${ease.x1}, ${ease.y1}, ${ease.x2}, ${ease.y2})`} ${delay}ms`,
overflow: "hidden",
display: singleLine ? "flex" : "block",
};
const isHorizontal = direction === "left" || direction === "right";
const floatStyle = isHorizontal
? {
float: "left",
overflow: "hidden",
display: singleLine ? "flex" : "block",
flexShrink: singleLine ? 0 : 1,
}
: { overflow: "hidden" };
const baseFontSize = isRemUnit
? Number.parseInt(window.getComputedStyle(document.documentElement, null).fontSize)
: 1;
const realSingleStopWidth = singleWidth * baseFontSize;
const realSingleStopHeight = singleHeight * baseFontSize;
useEffect(() => {
if (realSingleStopHeight > 0 && realSingleStopHeight % step > 0) {
console.error(
"如果设置了单步滚动step 需是单步大小的约数,否则无法保证单步滚动结束的位置是否准确。~~~~~",
);
}
}, [realSingleStopHeight, step]);
/**
* 取消当前动画帧
*/
const cancle = () => {
if (reqFrame.current) {
cancelAnimationFrame(reqFrame.current);
reqFrame.current = null;
}
};
/**
* 核心动画函数
*
* @description
* 负责执行一帧的滚动动画包括
* 1. 检查是否到达边界滚动到一半的高度
* 2. 如果到达边界重置位置到开头/结尾
* 3. 继续滚动一步
* 4. 处理单步滚动的等待逻辑
*
* @param {string} _direction - 滚动方向'up' | 'down' | 'left' | 'right'
* @param {number} _step - 每次滚动的步进距离
* @param {boolean} isWheel - 是否由滚轮触发
*/
const animation = (
_direction,
_step,
isWheel,
) => {
reqFrame.current = requestAnimationFrame(() => {
// 计算一半的高度/宽度(即原始内容的高度/宽度)
const h = realBoxHeight / 2;
const w = realBoxWidth / 2;
// 根据滚动方向确保尺寸有效
if ((_direction === "up" || _direction === "down") && h <= 0) {
return;
}
if ((_direction === "left" || _direction === "right") && w <= 0) {
return;
}
// 记录新位置,用于后续判断是否需要单步等待
let newYPos = yPos;
let newXPos = xPos;
// ==================== 向上滚动 ====================
if (_direction === "up") {
setYPos((prev) => {
// 当向上滚动到达边界(已经滚动了一半的高度)时
if (Math.abs(prev) >= h) {
setCount(c => c + 1);
newYPos = -_step;
return -_step; // 重置到第一步的位置而不是0因为还要继续滚动一步
}
newYPos = prev - _step;
return prev - _step; // 继续向上滚动一步
});
}
// ==================== 向下滚动 ====================
else if (_direction === "down") {
setYPos((prev) => {
if (prev >= 0) {
setCount(c => c + 1);
newYPos = (h * -1) + _step;
return (h * -1) + _step; // 重置到倒数第一步的位置
}
newYPos = prev + _step;
return prev + _step; // 继续向下滚动一步
});
}
// ==================== 向左滚动 ====================
else if (_direction === "left") {
setXPos((prev) => {
if (Math.abs(prev) >= w) {
setCount(c => c + 1);
newXPos = -_step;
return -_step;
}
newXPos = prev - _step;
return prev - _step; // 继续向左滚动一步
});
}
// ==================== 向右滚动 ====================
else if (_direction === "right") {
setXPos((prev) => {
if (prev >= 0) {
setCount(c => c + 1);
newXPos = (w * -1) + _step;
return (w * -1) + _step;
}
newXPos = prev + _step;
return prev + _step; // 继续向右滚动一步
});
}
// 如果是滚轮触发,不继续下一帧
if (isWheel) {
return;
}
// ==================== 单步滚动等待逻辑 ====================
// 清除之前的等待定时器
if (singleWaitTimeout.current) {
clearTimeout(singleWaitTimeout.current);
}
// 如果设置了单步停止高度(垂直方向)
if (realSingleStopHeight) {
// 如果当前位置到达了单步停止点,等待一段时间后再继续
if (Math.abs(newYPos) % realSingleStopHeight < _step) {
singleWaitTimeout.current = window.setTimeout(() => {
move();
}, singleWaitTime);
}
else {
move(); // 立即继续下一帧
}
}
// 如果设置了单步停止宽度(水平方向)
else if (realSingleStopWidth) {
if (Math.abs(newXPos) % realSingleStopWidth < _step) {
singleWaitTimeout.current = window.setTimeout(() => {
move();
}, singleWaitTime);
}
else {
move();
}
}
// 没有设置单步停止,持续滚动
else {
move();
}
});
};
/**
* 启动下一帧动画
*
* @description
* 检查是否满足滚动条件
* 1. 没有鼠标悬停
* 2. 列表长度超过限制
* 3. 没有达到最大循环次数
* 如果满足条件调用 animation 执行下一帧
*/
const move = () => {
cancle();
if (isHoverRef.current || !isScroll || _count === count) {
setCount(0);
return;
}
animation(direction, step, false);
};
/**
* 初始化滚动
*
* @description
* 1. 计算并设置容器的宽度和高度
* 2. 如果需要滚动且 value true启动滚动动画
*
* @note
* - React useEffect 中调用可能存在 DOM 未完全渲染的情况
* - 因此使用 requestAnimationFrame 确保 DOM 已渲染
*/
const initMove = () => {
if (list && typeof list !== "boolean" && list.length > 100) {
console.warn(`数据达到了${list.length}条有点多哦~,可能会造成部分老旧浏览器卡顿。`);
}
if (isHorizontal) {
let slotListWidth = slotListRef.current?.offsetWidth || 0;
slotListWidth = slotListWidth * 2 + 1;
setRealBoxWidth(slotListWidth);
}
if (isScroll) {
// 使用 requestAnimationFrame 确保 DOM 完全渲染后再获取高度
requestAnimationFrame(() => {
const height = realBoxRef.current?.offsetHeight || 0;
if (height > 0) {
setRealBoxHeight(height);
}
else {
// 如果获取失败,延迟重试
setTimeout(() => {
const retryHeight = realBoxRef.current?.offsetHeight || 0;
if (retryHeight > 0) {
setRealBoxHeight(retryHeight);
}
}, 100);
}
});
}
else {
cancle();
setXPos(0);
setYPos(0);
}
};
/**
* 开始滚动鼠标移出时调用
*
* @description
* 1. 更新 hover 状态为 false
* 2. 启动滚动动画
*
* @note
* 使用 isHoverRef.current 立即更新状态避免 React state 异步更新导致的问题
*/
const startMove = () => {
isHoverRef.current = false; // 立即更新 ref
move();
};
/**
* 停止滚动鼠标移入时调用
*
* @description
* 1. 更新 hover 状态为 true
* 2. 清除单步等待定时器
* 3. 取消当前动画帧
*/
const stopMove = () => {
isHoverRef.current = true; // 立即更新 ref
if (singleWaitTimeout.current) {
clearTimeout(singleWaitTimeout.current);
}
cancle();
};
const hoverStop = hover && value && isScroll; // 是否启用悬停停止功能
/**
* 重置滚动状态
*
* @description
* 1. 取消当前动画
* 2. 清除 hover 状态
* 3. 重新初始化滚动
*
* @note
* 对外暴露的方法可通过 ref 调用
*/
const reset = () => {
cancle();
initMove();
};
/**
* 滚轮事件处理节流
*/
const onWheel = throttle(30, (e) => {
cancle();
const singleHeight = realSingleStopHeight || 15;
if (e.deltaY < 0) {
animation("down", singleHeight, true);
}
if (e.deltaY > 0) {
animation("up", singleHeight, true);
}
});
// 对外暴露方法
useImperativeHandle(ref, () => ({
reset,
}));
// ==================== Effects ====================
/**
* 初始化效果
* isScroll 状态改变时重新初始化滚动
*/
useEffect(() => {
if (isScroll) {
initMove();
}
}, [isScroll]);
/**
* 高度/宽度变化后启动滚动
* realBoxHeight realBoxWidth 设置完成后自动启动滚动
*
* @note
* 这个 useEffect 解决了 DOM 渲染延迟导致的问题
* - initMove 中异步获取高度
* - 高度设置后触发此 useEffect
* - 然后启动滚动
*/
useEffect(() => {
if (isScroll && value && (realBoxHeight > 0 || realBoxWidth > 0) && !isHoverRef.current) {
move();
}
}, [realBoxHeight, realBoxWidth]);
/**
* 监听 value 属性变化
* value 变化时启动或停止滚动
*/
useEffect(() => {
if (value) {
startMove();
}
else {
stopMove();
}
}, [value]);
/**
* 监听 count 属性变化
* count 不为 0 重新启动滚动
*
* @note
* count 表示最大循环次数达到后会停止
* 通过改变 count 属性可以重新启动滚动
*/
useEffect(() => {
if (count !== 0) {
startMove();
}
}, [count]);
/**
* 监听 list 数据变化
* list isWatch 变化时重置滚动
*/
useEffect(() => {
if (isWatch) {
reset();
}
}, [list, isWatch]);
/**
* 清理效果
* 组件卸载时清除所有定时器和动画帧
*/
useEffect(() => {
return () => {
cancle();
if (singleWaitTimeout.current) {
clearTimeout(singleWaitTimeout.current);
}
};
}, []);
/**
* 生成滚动内容的 HTML
*
* @description
* 1. 渲染原始列表
* 2. 如果需要滚动额外渲染一份拷贝
* 3. 这样当滚动到一半时可以无缝重置到开头
*/
const getHtml = () => {
return (
<>
<div ref={slotListRef} style={floatStyle}>
{children}
</div>
{isScroll
? Array.from({ length: copyNum }).map((_, index) => (
<div key={`copy-${index}`} style={floatStyle}>
{children}
</div>
))
: null}
</>
);
};
/**
* 渲染组件
*
* @description
* 1. 根据是否启用滚轮功能渲染不同的结构
* 2. 处理鼠标悬停事件
*/
return (
<div ref={scrollRef} className={className}>
{/* 如果启用了滚轮和悬停功能,添加 onWheel 事件 */}
{wheel && hover
? (
<div
ref={realBoxRef}
style={realBoxStyle}
onMouseEnter={() => {
if (hoverStop) {
stopMove();
}
}}
onMouseLeave={() => {
if (hoverStop) {
startMove();
}
}}
onWheel={(e) => {
if (hoverStop) {
onWheel(e);
}
}}
>
{getHtml()}
</div>
)
: (
<div
ref={realBoxRef}
style={realBoxStyle}
onMouseEnter={() => {
if (hoverStop) {
stopMove();
}
}}
onMouseLeave={() => {
if (hoverStop) {
startMove();
}
}}
>
{getHtml()}
</div>
)}
</div>
);
});
SeamlessScroll.displayName = "SeamlessScroll";
export default SeamlessScroll;