Flutter实现酷狗歌词逐字上色的效果

4,884 阅读3分钟

作为酷狗音乐的忠实使用者,一直都觉得酷狗App的UI交互很酷炫,在业界中当属前列。这次我研究上了他的歌词滚动效果,逐字变色如果用Flutter来实现,应该是个很有趣的事情。

效果图

yuan.gif

kg.gif

动效分析

研究酷狗的歌词滚动效果可以发现:

  • 歌词是逐字变色的
  • 针对单个字,是根据单个歌词的演唱耗时来上色

开发思路

  1. 首先需要明确的是每行代表一句歌词,这句歌词播放的总时长是固定的,然后每个字播放的时长也是固定的。每个字上色的动画连接起来,就是这一句歌词的总体上色动画。因此我们把颗粒度细致到“字”上
  2. 歌词我们使用文本渲染即可,上色的效果我们可以用文字的前景色属性foreground来叠加渐变,从而实现一段文字不同颜色的效果。

实现方案

1. mock数据

常规的开发第一步:定义好数据模型。这里我构造的数据特别简单,只服务于实现逐字上色的效果。

class Model {
  int totalTime = 12284; // 歌词总时长
  String content = '我要去看那最远的地方,和你手舞足蹈聊梦想'; // 歌词内容
  List<Word> lyric = [
    Word('我', 500),
    Word('要', 500),
    Word('去', 400),
    Word('看', 280),
    Word('那', 3320),
    Word('最', 310),
    Word('远', 390),
    Word('的', 420),
    Word('地', 400),
    Word('方', 380),
    Word(',', 700),
    Word('和', 620),
    Word('你', 580),
    Word('手', 340),
    Word('舞', 356),
    Word('足', 356),
    Word('蹈', 420),
    Word('聊', 580),
    Word('梦', 552),
    Word('想', 780),
  ];
}

class Word {
  final String text;
  final int duration;

  Word(this.text, this.duration);
}

2. 布局渲染

要实现逐字上色效果,关键知识点是:利用TextStyle的foreground属性,定义一个渐变,基于渐变创建着色器Shader复杂给foreground即可。

Gradient gradient = const LinearGradient(colors: [Colors.red, Colors.grey]);
// ......
Text(
  key: _key,
  currLyric.content,
  style: TextStyle(
    fontSize: 16.0,
    foreground: Paint()
      ..shader = gradient.createShader(
        Rect.fromLTWH(
          animation.value,
          textOffset.dy,
          textOffset.dx,
          textSize.height,
        ),
        textDirection: TextDirection.ltr,
      ),
  ),
),

3. 单字上色动画(附上完整代码)

  • 要实现单字上色的动画,需要先了解createShader中的Rect属性,基于传入的左上坐标点,绘制一个矩形,矩形大小也是我们传进去的;
  • 涉及到位置和大小,就需要先拿到这句歌词的渲染信息,这里我们很自然的使用GlobalKey,通过RenderObject获取渲染信息;
  • 每行歌词的长度是固定的,每个字的宽度也是固定的。因此我们只要在单字的播放时间内,着色器渲染的起始点从单字前的位置,平缓的移动到单字后的位置即可。 这里就已经给出了Animation和Controller所需要的变量了。
class Lyric extends StatefulWidget {
  const Lyric({Key? key}) : super(key: key);

  @override
  State<Lyric> createState() => _LyricState();
}

class _LyricState extends State<Lyric> with TickerProviderStateMixin {
  Gradient gradient = const LinearGradient(colors: [Colors.red, Colors.grey]);

  Model currLyric = Model();
  final GlobalKey _key = GlobalKey();

  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: Duration(milliseconds: currLyric.lyric[count].duration),
      vsync: this,
    );
    animation = Tween(
      begin: 0.0,
      end: 0.0,
    ).animate(controller);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('歌词动效'),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 0),
          child: AnimatedBuilder(
            animation: animation,
            builder: (_, __) => Text(
              key: _key,
              currLyric.content,
              style: TextStyle(
                fontSize: 16.0,
                foreground: Paint()
                  ..shader = gradient.createShader(
                    Rect.fromLTWH(
                      animation.value,
                      textOffset.dy,
                      textOffset.dx,
                      textSize.height,
                    ),
                    textDirection: TextDirection.ltr,
                  ),
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _starPlay,
        tooltip: 'Increment',
        child: const Icon(Icons.play_circle_outline),
      ), // T
    );
  }

  int count = 0;
  Size textSize = Size.zero;
  Offset textOffset = Offset.zero;

  _starPlay() {
    if (_key.currentContext != null) {
      RenderBox renderBox =
          _key.currentContext!.findRenderObject() as RenderBox;
      // offset.dx , offset.dy 就是控件的左上角坐标
      textOffset = renderBox.localToGlobal(Offset.zero);
      textSize = renderBox.size; // 获取size

      debugPrint(
          'offset=$textOffset, size=$textSize, width=${MediaQuery.of(context).size.width}');
      singlePlay();
    }
  }

  singlePlay() async {
    debugPrint('当前播放歌词的时长=${currLyric.lyric[count].duration}');
    controller = AnimationController(
      duration: Duration(milliseconds: currLyric.lyric[count].duration),
      vsync: this,
    );
    double width = textSize.width + textOffset.dx;
    animation = Tween(
      begin: width * (count / currLyric.lyric.length),
      end: width * ((count + 1) / currLyric.lyric.length),
    ).animate(controller);
    animation.addListener(() {
      setState(() {});
    });
    animation.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        debugPrint('播放完第${count + 1}个歌词');
        count++;
        controller.dispose();
        if (count < currLyric.lyric.length) {
          singlePlay();
        } else {
          count = 0;
        }
      }
    });
    controller.forward();
  }
}

写在最后

至此,歌词逐字上色的效果就编写完啦。总体是很简单的,但是效果拆解完的细节也不少。
对于酷狗的效果,此前我也曾写过酷狗的 流畅Tabbar,我会继续往下实现更多酷炫的效果,当然也不止于酷狗~