本文由 Loui 原创,如需转载,请先私信或评论。
介绍
本文代码内容来自我正在做的一款高仿酷我音乐开源项目,下面是滚动歌词组件的最终效果:

歌词会根据播放时间实时滚动到最中间,并高亮显示,此外还可以手动鼠标上下滚动查看歌词。
背景情况
为了方便你快速将本文中的实现方式迁移到自己的项目中,以下是对该项目背景情况的一些介绍,仅供参考,可跳过。
1. 歌词从哪来的?
在该项目中,滚动歌词部分被封装成了一个独立的组件,在组件中,使用 RTK Query 访问相关接口获取歌词数据,歌词数据是一个对象的数组,格式如下:

2. 当前播放的时间从哪来的?
在该项目中,当前播放时间、当前播放的音乐信息都存储在了 redux 中,通过 useSelector 获取。
3. CSS 用的是哪种?
该项目使用 CSS Module + Scss
实现
结构及样式

结构:
tsx
{isFetching ? (<p>正在加载中,请稍后</p>) : lyricData?.data.lrclist ? (
{/* 只有加载完毕了,且得到的歌词数据不为空,才显示滚动歌词的部分 */}
<div className={styles['lyric']}>
<div
style={{
transform: `translateY(0px)`, {/* 这里待会要改,暂时写成默认的 0 ,不偏移 */}
}}
className={styles['scroll']}
>
{/* 使用 map 方法遍历歌词数据,每一个时间点的歌词生成一个 p */}
{lyricData.data.lrclist.map((item) =>
{/* 如果歌词不为空,才生成 p,否则没必要生成一个空白的 p */}
item.lineLyric.trim() !== '' ? (
<p
key={`${item.time}-${item.lineLyric}`}
className={styles['line']} {/* 这里待会要改,还要一个 active 类 */}
>
{item.lineLyric}
</p>
) : null,
)}
</div>
</div>
): (<p>暂无歌词</p>)
}
样式:
scss
.lyric {
position: relative; // 最外层的 div 为相对定位
height: 400px;
overflow: hidden; // 溢出隐藏
.scroll {
position: absolute; // 用于滚动的 div 为绝对定位
width: 100%;
height: fit-content;
transition: all 0.3s ease-out; // 加上滚动动画
// 设置每一行歌词的样式
.line {
height: 50px;
overflow: hidden;
font-size: 16px;
font-weight: 300;
line-height: 50px;
color: #999;
text-align: center;
text-overflow: ellipsis;
text-wrap: nowrap;
letter-spacing: 1.5px;
// 通过 active 类实现高亮、加粗显示当前正在播放的歌词
&.active {
font-weight: 600;
color: #333;
}
}
}
// 使用 before 伪元素实现顶部渐变的白色阴影遮罩效果
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 500;
width: 100%;
height: 80px;
content: '';
background: linear-gradient(180deg, #fff 0%, rgb(255 255 255 / 60%) 40%, rgb(255 255 255 / 10%) 100%);
}
// 使用 after 伪元素实现底部渐变的白色阴影遮罩效果
&::after {
position: absolute;
bottom: 0;
left: 0;
z-index: 500;
width: 100%;
height: 80px;
content: '';
background: linear-gradient(0deg, #fff 0%, rgb(255 255 255 / 60%) 40%, rgb(255 255 255 / 10%) 100%);
}
}
逻辑
加入逻辑:
主要需要考虑两个方面的逻辑:①自动将当前的歌词滚动到正中间 ②鼠标手动在盒子上滚动时,内层的盒子的 translateY 需要跟着变化,而且还要避免页面滚动
tsx
// 使用 ref 获取到三个 dom 元素,分别是 最外面的盒子、用于滚动的盒子、当前歌词的 p 标签
const lyricDiv = useRef<HTMLDivElement>(null)
const scrollDiv = useRef<HTMLDivElement>(null)
const currentP = useRef<HTMLParagraphElement>(null)
// 获取当前播放时间、当前播放的音乐信息,我这里直接都放在 redux 中了,你需要结合自己的项目去修改
const currentTime = useSelector((state: RootState) => state.player.currentTime)
const currentMusic = useSelector((state: RootState) => state.player.currentMusic)
// 获取歌词数据,我这里使用 RTK Query 进行获取,你需要结合自己的项目去修改
const { data: lyricData, isFetching } = useGetLyricQuery({ id: params.id || '' })
// 根据当前播放时间,得到当前歌词的数据
const currentLine = useMemo(() => {
return (
currentMusic.rid &&
currentMusic.rid.toString() === params.id &&
lyricData?.data.lrclist.find((item, index) => {
return currentTime >= +item.time && currentTime < +(lyricData.data.lrclist[index + 1]?.time || 999999)
})
)
}, [lyricData, currentTime, currentMusic.rid, params.id])
// 监听播放时间的变化,自动设置 .scroll 盒子的 translateY ,实现滚动歌词
const [offsetY, setOffsetY] = useState(-50)
useEffect(() => {
if (currentMusic.rid && currentMusic.rid.toString() === params.id && scrollDiv.current && currentP.current) {
const boxRect = lyricDiv.current!.getBoundingClientRect()
const divRect = scrollDiv.current!.getBoundingClientRect()
const pRect = currentP.current!.getBoundingClientRect()
setOffsetY(pRect.top - divRect.top - boxRect.height / 2 + pRect.height / 2)
} else {
setOffsetY(-50)
}
}, [currentLine, currentMusic.rid, params.id])
// 处理鼠标手动滚动歌词
const handleScroll = (e: WheelEvent<HTMLDivElement>) => {
if (!scrollDiv.current || !lyricDiv.current) return
// 避免页面跟着滚动
e.preventDefault()
// 获取之前的 translateY
const currentOffset = scrollDiv.current.getBoundingClientRect().top - lyricDiv.current.getBoundingClientRect().top
// 计算滚动到最底部时可以达到的最大 translateY
const bottomScrollOffset = -(scrollDiv.current.getBoundingClientRect().height - lyricDiv.current.getBoundingClientRect().height / 2)
// 计算滚动到最顶部时可以达到的最小 translateY
const topScrollOffset = -(-lyricDiv.current.getBoundingClientRect().height / 2)
let newOffset = currentOffset
// 鼠标滚轮往下走
if (e.deltaY > 0 && currentOffset > bottomScrollOffset) {
newOffset -= e.deltaY * 2.3
}
// 鼠标滚轮往上走
else if (e.deltaY < 0 && currentOffset < topScrollOffset) {
newOffset -= e.deltaY * 2.3
}
// 保证新的 translateY 不会超过最大、最小值,即保证歌词不会滚到不见了
if (newOffset < bottomScrollOffset) {
newOffset = bottomScrollOffset // Ensure not to scroll above the top
} else if (newOffset > topScrollOffset) {
newOffset = topScrollOffset // Ensure not to scroll below the bottom
}
scrollDiv.current.style.transform = `translateY(${newOffset}px)`
}
// 由于要在鼠标滚动事件中 preventDefault 避免页面发生滚动,所以要设置 passive: false,因此只能使用 useEffect 手动绑定鼠标滚动事件
useEffect(() => {
const lyric = lyricDiv.current
if (lyric) {
lyric.addEventListener(
'wheel',
(e) => {
handleScroll(e as unknown as WheelEvent<HTMLDivElement>)
},
{ passive: false },
)
}
return () => {
if (lyric) {
lyric.removeEventListener('wheel', (e) => {
handleScroll(e as unknown as WheelEvent<HTMLDivElement>)
})
}
}
}, [isFetching])
补充页面结构 tsx 代码:
tsx
{isFetching ? (<p>正在加载中,请稍后</p>) : lyricData?.data.lrclist ? (
{/* 1. 给 .lyric 加上 ref */}
<div ref={lyricDiv} className={styles['lyric']}>
<div
{/* 2. 给 .scroll 加上 ref */}
ref={scrollDiv}
style={{
transform: `translateY(${-offsetY}px)`, {/* 5. 设置 translateY */}
}}
className={styles['scroll']}
>
{lyricData.data.lrclist.map((item) =>
item.lineLyric.trim() !== '' ? (
<p
{/* 3. 给当前歌词的 p 加上 ref */}
ref={currentLine === item ? currentP : null}
key={`${item.time}-${item.lineLyric}`}
{/* 4. 设置 active 类名 */}
className={classNames(styles['line'], {
[styles['active']]: currentLine === item,
})}
>
{item.lineLyric}
</p>
) : null,
)}
</div>
</div>
): (<p>暂无歌词</p>)
}
总结
实现歌词滚动效果的方法有很多,本文中介绍的实现方式是通过设置内层 div 的 translateY 实现的。
结构方面,采用的是 外层 div > 内层 div > 多个 p。
样式方面,通过给外层 div 设置伪元素实现白色渐变遮罩。
逻辑方面,主要考虑两点:① 监听当前播放时间,设置滚动位置 ② 处理鼠标滚动事件,设置滚动位置。
如果这篇文章有帮到你,还请点一个不要钱的赞,感谢支持 ❤️