新增SeamlessScroll无缝滚动组件
parent
2746cca0c3
commit
5e6f280dfb
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue