注册
web

实现丝滑的无缝滚动轮播图

一. 目标效果


2023-12-15-13-59-18.gif


二. 实现思路


使用Animate API或者CSS动画的方式都可以,我选择的是Animate API。


实现无缝滚动的一般思路


Translate位移+无限循环动画。但是这样会有一个小问题,就是在动画结束又开始的一瞬间会闪烁一下,不是很完美。


解决方法


复制一份数据, 原来的1份数据变成2份数据。然后动画的关键帧设置位移的终点为50%,这样每次动画的结束帧就在数据的中间位置, 注意如果数据之间有间距的话,还要加上间距的一半。这样即可实现无限滚动,并且足够丝滑。3


三. 实现


核心代码


以下代码示例都使用React框架。


  // 使用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


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


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


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;
}


四、如何使用


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>

);
}


作者:In74
来源:juejin.cn/post/7312421872414818331

0 个评论

要回复文章请先登录注册