Last active: a year ago
useTextScroll.ts
import { nanoid } from 'nanoid';
import { useEffect } from 'react';
import styles from './useTextScroll.module.less';
const addTransformFrames = (
name: string,
transformX: number,
styleElement: HTMLStyleElement
) => {
// styleElement.type = 'text/css';
const keyFrames = `
@-webkit-keyframes ${name} {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(${transformX}px);
}
}
@-moz-keyframes ${name} {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(${transformX}px);
}
}
@keyframes ${name} {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(${transformX}px);
}
}
`;
styleElement.innerHTML += keyFrames;
document.head.append(styleElement);
};
type UseTextScrollProps = {
// 父元素 ref
ref: React.RefObject<HTMLDivElement>;
// 父元素最大宽度
maxWidth: string;
// 相同方向无限滚动
sameDirection?: boolean;
};
// 复用 style 标签,并在组件卸载时清空内容
const style = document.createElement('style');
/**
* 使超出父元素的子元素文字内容进行滚动
*
* 利用计算父元素的 `clientWidth` 与子元素
* 可滚动宽度 `scrollWidth` 的差值来配合
* CSS `transformX` 实现滚动动画(走马灯动画)
*
* 使用:
*
* ```tsx
* // 素材名称滚动
* const nameRef = useRef<HTMLDivElement>(null);
* useTextScroll({
* ref: nameRef,
* maxWidth: '5em',
* });
* ```
*/
const useTextScroll = ({
ref,
maxWidth,
sameDirection,
}: UseTextScrollProps) => {
useEffect(() => {
if (!ref.current) return;
const wrapper = ref.current;
wrapper.style.maxWidth = maxWidth;
wrapper.classList.add(styles.wrapper);
const child0 = wrapper.children[0];
if (!child0) return;
// 来回滚动动画
const rollBack = () => {
child0.classList.add(styles.name);
// child 需要使用 scrollWidth 来获得可滚动的宽度
setTimeout(() => {
const width = wrapper.clientWidth - child0.scrollWidth;
const keyName = `scroll-${nanoid()}`;
width < 0 && addTransformFrames(keyName, width, style);
(child0 as HTMLSpanElement).style.animation = `${
Math.abs(width) * 150
}ms linear infinite alternate ${keyName}`;
}, 100);
};
// 相同方向动画
const rolling = () => {
// child 需要使用 scrollWidth 来获得可滚动的宽度
setTimeout(() => {
const childOffsetWidth = child0.scrollWidth;
const width = wrapper.clientWidth - childOffsetWidth;
if (width >= 0) return;
/**
* 克隆多个子元素,当第一个子元素过渡完后
* 重置整个过渡动画,以实现循环
*/
const childWrapper = document.createElement('div');
childWrapper.classList.add(styles['infinite-wrapper']);
// Make a child clone
const child1 = child0.cloneNode(true);
// Remove all children of parent.
wrapper.removeChild(child0);
// Add a wrapper for chlidren.
childWrapper.append(child0);
childWrapper.append(child1);
wrapper.append(childWrapper);
child0.classList.add(styles['infinite-name']);
(child1 as HTMLSpanElement).classList.add(styles['infinite-name']);
/**
* 循环滚动动画,类似 swiper
* 开始时先清空所有过渡动画,并在下个任务队列中添加
* 对应 translate 过渡。
* 通过监听元素 transitionend 事件,判断过渡是否完成。
* 当过渡完成后,重置过渡
*/
const startScroll = () => {
childWrapper.style.transition = ``;
childWrapper.style.transform = ``;
setTimeout(() => {
childWrapper.style.transition = `all ${
Math.abs(width) * 150
}ms linear`;
// 添加 marginRight 的距离
childWrapper.style.transform = `translateX(${
-childOffsetWidth -
parseInt(window.getComputedStyle(child0).marginRight)
}px)`;
// 过渡结束后重新开始
childWrapper.addEventListener('transitionend', startScroll);
}, 0);
};
startScroll();
}, 100);
};
sameDirection ? rolling() : rollBack();
return () => {
style.innerHTML = '';
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};
export default useTextScroll;