React 之仿酷我音乐 - 优雅地实现滚动歌词效果

本文由 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 设置伪元素实现白色渐变遮罩。

逻辑方面,主要考虑两点:① 监听当前播放时间,设置滚动位置 ② 处理鼠标滚动事件,设置滚动位置。

如果这篇文章有帮到你,还请点一个不要钱的赞,感谢支持 ❤️

相关推荐
小小小小宇6 小时前
虚拟列表兼容老DOM操作
前端
悦悦子a啊6 小时前
Python之--基本知识
开发语言·前端·python
安全系统学习7 小时前
系统安全之大模型案例分析
前端·安全·web安全·网络安全·xss
涛哥码咖7 小时前
chrome安装AXURE插件后无效
前端·chrome·axure
OEC小胖胖7 小时前
告别 undefined is not a function:TypeScript 前端开发优势与实践指南
前端·javascript·typescript·web
行云&流水7 小时前
Vue3 Lifecycle Hooks
前端·javascript·vue.js
Sally璐璐8 小时前
零基础学HTML和CSS:网页设计入门
前端·css
老虎06278 小时前
JavaWeb(苍穹外卖)--学习笔记04(前端:HTML,CSS,JavaScript)
前端·javascript·css·笔记·学习·html
灿灿121388 小时前
CSS 文字浮雕效果:巧用 text-shadow 实现 3D 立体文字
前端·css
烛阴8 小时前
Babel 完全上手指南:从零开始解锁现代 JavaScript 开发的超能力!
前端·javascript