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

一. 目标效果

二. 实现思路

使用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>
  );
}
相关推荐
new出一个对象1 小时前
uniapp接入BMapGL百度地图
javascript·百度·uni-app
你挚爱的强哥2 小时前
✅✅✅【Vue.js】sd.js基于jQuery Ajax最新原生完整版for凯哥API版本
javascript·vue.js·jquery
前端Hardy3 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189113 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
小镇程序员6 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
疯狂的沙粒6 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪6 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背6 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M7 小时前
node.js第三方Express 框架
前端·javascript·node.js·express
weiabc7 小时前
学习electron
javascript·学习·electron