作为酷狗音乐的忠实使用者,一直都觉得酷狗App的UI交互很酷炫,在业界中当属前列。这次我研究上了他的歌词滚动效果,逐字变色如果用Flutter来实现,应该是个很有趣的事情。
效果图
动效分析
研究酷狗的歌词滚动效果可以发现:
- 歌词是逐字变色的;
- 针对单个字,是根据单个歌词的演唱耗时来上色;
开发思路
- 首先需要明确的是每行代表一句歌词,这句歌词播放的总时长是固定的,然后每个字播放的时长也是固定的。每个字上色的动画连接起来,就是这一句歌词的总体上色动画。因此我们把颗粒度细致到“字”上。
- 歌词我们使用文本渲染即可,上色的效果我们可以用文字的前景色属性
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,我会继续往下实现更多酷炫的效果,当然也不止于酷狗~