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

一. 目标效果

二. 实现思路

使用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>
  );
}
相关推荐
无风听海8 分钟前
Promise 与 Async Await 深度解析
前端·javascript
橘子味的冰淇淋~1 小时前
优化前端性能之从“全局引入”改为“按需引入”
前端·javascript·vue.js
Vennn1 小时前
Android自动化:使用 Web 方式实现某音未读消息检查与采集
前端·javascript·vue.js
Smilezyl1 小时前
为了搞懂 AI Agent,我用 6000 行 JS 代码手搓了一个零依赖的 Coding Agent
前端·javascript·github
掰头战士1 小时前
搞定JavaScript类型判断,一文就够了
javascript
周凡1232 小时前
AI 时代的 Web JavaScript 逆向分析实践与思考
前端·javascript·人工智能
zhoumeina992 小时前
分段创建产品,tab 页切换又要保留缓存
前端·javascript
The Sheep 20232 小时前
EFcore 查询数据
java·javascript
怕浪猫2 小时前
Electron 开发实战(七):网络通信与 API 集成全解
前端·javascript·electron