一. 目标效果
二. 实现思路
使用Animate API或者CSS动画的方式都可以,我选择的是Animate API。
实现无缝滚动的一般思路
Translate位移+无限循环动画。但是这样会有一个小问题,就是在动画结束又开始的一瞬间会闪烁一下,不是很完美。
解决方法
复制一份数据, 原来的1份数据变成2份数据。然后动画的关键帧设置位移的终点为50%,这样每次动画的结束帧就在数据的中间位置, 注意如果数据之间有间距的话,还要加上间距的一半。这样即可实现无限滚动,并且足够丝滑。3
三. 实现
核心代码
以下代码示例都使用React框架。
TypeScript
// 使用Web animate Api 添加动画
useEffect(() => {
if (!container.current) return;
// 获取gap值
const gap = getComputedStyle(container.current).gap.split('px')[0] ?? 0;
// 滚动容器(container)的50%宽度 + 滚动容器的50%gap值
// 如果不加滚动容器的50%gap值, 在动画结束又开始的瞬间会跳一下
const translateX = container.current.clientWidth / 2 + Number(gap) / 2;
if (isNaN(translateX)) {
throw new Error('translateX is NaN!');
}
// 定义关键帧, 执行动画
let keyframes: Keyframe[] = [];
if (type === 'rtl') {
keyframes = [
{
transform: 'translateX(0)',
},
{
transform: `translateX(-${translateX}px)`,
},
];
} else if (type === 'ltr') {
keyframes = [
{
transform: `translateX(-${translateX}px)`,
},
{
transform: 'translateX(0)',
},
];
}
animation.current = container.current.animate(keyframes, {
duration,
easing: 'linear',
iterations: Infinity,
});
}, []);
return (
// 使用context传递store和dispatch
<SwiperContext.Provider value={{ store, dispatch }}>
<div className={classNames(['w-full overflow-x-hidden', wrapperClassName])}>
{/* 使用inline-flex代替flex,让ul的宽度被子元素撑开 */}
<ul
className={classNames(['inline-flex flex-nowrap gap-5', className])}
style={style}
ref={container}
>
{/* 类似于HOC的效果, 为Item组件添加_key */}
{children.map((child) =>
cloneElement(child as ReactElement, {
_key: (child as ReactElement).key ?? '',
})
)}
{/* 实现无缝滚动, 复制一组子元素进行占位 */}
{children.map((child) =>
cloneElement(child as ReactElement, {
_key: (child as ReactElement).key ?? '',
})
)}
</ul>
</div>
</SwiperContext.Provider>
);
其中type只是为了区分在x轴上的滚动方向而已,根据方向应用不同的动画。动画的实例使用useRef()去保存。 方便后续调用此动画实例进行动画的暂停和播放。
完整代码
SwiperBox.tsx
TypeScript
import { useHover } from 'ahooks';
import classNames from 'classnames';
import { isNaN, isUndefined } from 'lodash-es';
import {
cloneElement,
CSSProperties,
ReactElement,
ReactNode,
useContext,
useEffect,
useRef,
} from 'react';
import { SwiperContext } from './swiper-context';
import useSwiperReducer, { SwiperActions } from './use-swiper-reducer';
interface SwiperBoxProp {
/**
* 轮播方向
*
* @type {('ltr' | 'rtl')}
* @memberOf SwiperBoxProp
*/
type: 'ltr' | 'rtl';
/**
* 子节点
*
* @type {ReactNode[]}
* @memberOf SwiperBoxProp
*/
children: ReactNode[];
/**
* 类名
*
* @type {string}
* @memberOf SwiperBoxProp
*/
className?: string;
/**
* 外层节点类名
*
* @type {string}
* @memberOf SwiperBoxProp
*/
wrapperClassName?: string;
/**
* 节点样式
*
* @type {CSSProperties}
* @memberOf SwiperBoxProp
*/
style?: CSSProperties;
/**
* 动画持续时间
*
* @type {EffectTiming['duration']}
* @memberOf SwiperBoxProp
*/
duration?: EffectTiming['duration'];
/**
* 鼠标悬停时触发
* @type {boolean} isHovering 是否悬停
* @type {string} key 节点key
*
* @memberOf SwiperBoxProp
*/
hoverOnChange?: (isHovering: boolean, key: string) => void;
}
/**
* 无限循环、无缝轮播组件
* 使用这个组件必须通过gap的方式(eg: gap-4)来设置滚动项之间的距离, 不能使用margin的方式, 不然无缝滚动会有问题
*/
function SwiperBox(prop: SwiperBoxProp) {
const {
type,
className,
wrapperClassName,
style,
children,
duration = 3000,
hoverOnChange,
} = prop;
const [store, dispatch] = useSwiperReducer();
const { activeKey } = store;
// 滚动容器
const container = useRef<HTMLUListElement>(null);
// 动画实例
const animation = useRef<Animation | null>(null);
// activeKey改变时通知外部组件
useEffect(() => {
hoverOnChange &&
!!Object.keys(activeKey).length &&
hoverOnChange(activeKey.isHovering, activeKey.key);
}, [activeKey]);
// 获取所有的key值并存储
useEffect(() => {
dispatch(
SwiperActions.updateKeys(children.map((child) => (child as ReactElement).key ?? ''))
);
}, []);
// 使用Web animate Api 添加动画
useEffect(() => {
if (!container.current) return;
// 获取gap值
const gap = getComputedStyle(container.current).gap.split('px')[0] ?? 0;
// 滚动容器(container)的50%宽度 + 滚动容器的50%gap值
// 如果不加滚动容器的50%gap值, 在动画结束又开始的瞬间会跳一下
const translateX = container.current.clientWidth / 2 + Number(gap) / 2;
if (isNaN(translateX)) {
throw new Error('translateX is NaN!');
}
// 定义关键帧, 执行动画
let keyframes: Keyframe[] = [];
if (type === 'rtl') {
keyframes = [
{
transform: 'translateX(0)',
},
{
transform: `translateX(-${translateX}px)`,
},
];
} else if (type === 'ltr') {
keyframes = [
{
transform: `translateX(-${translateX}px)`,
},
{
transform: 'translateX(0)',
},
];
}
animation.current = container.current.animate(keyframes, {
duration,
easing: 'linear',
iterations: Infinity,
});
}, []);
// 鼠标移入动画暂停/播放
useEffect(() => {
if (!animation.current) return;
if (isUndefined(activeKey.isHovering)) return;
if (activeKey.isHovering) {
animation.current.pause();
} else {
animation.current.play();
}
}, [activeKey]);
return (
// 使用context传递store和dispatch
<SwiperContext.Provider value={{ store, dispatch }}>
<div className={classNames(['w-full overflow-x-hidden', wrapperClassName])}>
{/* 使用inline-flex代替flex,让ul的宽度被子元素撑开 */}
<ul
className={classNames(['inline-flex flex-nowrap gap-5', className])}
style={style}
ref={container}
>
{/* 类似于HOC的效果, 为Item组件添加_key */}
{children.map((child) =>
cloneElement(child as ReactElement, {
_key: (child as ReactElement).key ?? '',
})
)}
{/* 实现无缝滚动, 复制一组子元素进行占位 */}
{children.map((child) =>
cloneElement(child as ReactElement, {
_key: (child as ReactElement).key ?? '',
})
)}
</ul>
</div>
</SwiperContext.Provider>
);
}
interface SwiperBoxItemProp {
children: ReactNode;
// 唯一标识, React不会将key转发到组件中, 因此自定义一个唯一的_key
_key?: string;
}
function SwiperBoxItem(prop: SwiperBoxItemProp) {
const { children, _key } = prop;
const container = useRef<HTMLLIElement>(null);
const context = useContext(SwiperContext);
// 鼠标hover
const onEnter = () => {
context && _key && context.dispatch(SwiperActions.onEnter(true, _key));
};
// 鼠标退出hover
const onLeave = () => {
context && _key && context.dispatch(SwiperActions.onLeave(false, _key));
};
useHover(container, {
onEnter,
onLeave,
});
return (
<li
ref={container}
className="transition-transform duration-500 ease-out hover:scale-105"
>
{children}
</li>
);
}
const SwiperWithAnimation = {
Box: SwiperBox,
Item: SwiperBoxItem,
};
export default SwiperWithAnimation;
swiper-context.ts
TypeScript
import { createContext, Dispatch } from 'react';
import { SwiperAction, SwiperState } from './use-swiper-reducer';
export type SwiperContextType = {
store: SwiperState;
dispatch: Dispatch<SwiperAction>;
};
export const SwiperContext = createContext<SwiperContextType | null>(null);
useSwiperReducer.ts
TypeScript
import { useReducer } from 'react';
export interface SwiperState {
activeKey: { isHovering: boolean; key: string };
totalKeys: string[];
}
export type SwiperAction<T = any> = {
type: string;
payload: T;
};
export const SwiperActions = {
onEnter: (isHovering: boolean, key: string) => ({
type: 'onEnter',
payload: { isHovering, key },
}),
onLeave: (isHovering: boolean, key: string) => ({
type: 'onLeave',
payload: { isHovering, key },
}),
updateKeys: (keys: string[]) => ({
type: 'update_keys',
payload: keys,
}),
};
export default function useSwiperReducer() {
const initialState: SwiperState = {
activeKey: {} as SwiperState['activeKey'],
totalKeys: [] as SwiperState['totalKeys'],
};
const reducer = (store: SwiperState, { type, payload }: SwiperAction): SwiperState => {
switch (type) {
case 'onEnter':
return {
...store,
activeKey: payload,
};
case 'onLeave':
return {
...store,
activeKey: payload,
};
case 'update_keys':
return {
...store,
totalKeys: payload,
};
default:
return store;
}
};
const [store, dispatch] = useReducer(reducer, initialState);
return [store, dispatch] as const;
}
四、如何使用
TypeScript
import SwiperWithAnimation from '@/components/swiper-box/SwiperBox';
import { uniqueId } from 'lodash-es';
const DATA = new Array(2).fill(0).map(() => uniqueId('data'));
/**
* 测试页面
*/
export default function TestPage() {
// 鼠标hover事件
const hoverOnChange = (isHovering: boolean, key: string) => {
console.log('isHovering: ', isHovering);
console.log('key: ', key);
};
return (
<div>
<SwiperWithAnimation.Box
type="ltr"
wrapperClassName="py-9 m-auto !w-[600px] border border-red-200"
className="gap-8"
hoverOnChange={hoverOnChange}
>
{DATA.map((data) => (
<SwiperWithAnimation.Item key={data}>
<div className="f-c h-[300px] w-[300px] rounded-lg bg-theme-primary">
<div className="text-2xl">{data}</div>
</div>
</SwiperWithAnimation.Item>
))}
</SwiperWithAnimation.Box>
</div>
);
}