Flutter歌词渐变上色原理与实现 | 实验分享上一篇文章提及到我正在试着使用Flutter开发程序,在APP公开放出
👋前言
上一篇文章提及到我正在试着使用Flutter开发程序,在APP公开放出之前,我们先来一点前瞻,分享一下在这段时间开发中遇到的问题。
因为最近原生那边在学习车载APP通信和多屏幕开发的内容,也导致了这个APP的开发进展缓慢,等回头基本功能完善后再分享整个开发过程。
👁️效果分析
在开始之前我们先考虑实现单行的歌词动画,看看这个,有意思的是这种上色并不是一个字一个字的变色,而是多个字体颜色缓缓改变,就像是其他播放器那样,可能看完这个效果大家已经猜到实现方法啦?
这种情况一次直接“点亮”一个字是不太合理的,我们得考虑对一个行,或者一个字进行渐变,从而达到这个效果。
❓问题
正常情况下,线性渐变是如下图情况:
那对应的字体就是:
这显然不是我们想要的,看看前面的歌词效果,我们发现,歌词默认状态下全部是灰色,播放了的部分才有渐变效果。
💡巧妙思路
如果大家用PS会发现,这个渐变的起始位置是可以改变的,我们只需要让开始渐变的位置变得更远,大概是原来范围的2倍,就可以让所有的部分都是绿了,听上去有一些抽象?我们看看下面的图。
啊哈,这太棒了,看上去我们达到目的了,接下来我们利用这个方法显示渐变上色!
怎么样!看上去是不是就像是我们的歌词上色啦?
🔧开发实现
渐变实现
ShaderMask
可以对我们的组件设置着色,灵活度很高,不过今天我们不详细看它,如果你希望了解这控件更多,可以先看看其他文章。
让我们先看看简单的例子:
ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (Rect bounds) {
return LinearGradient(colors: [
Colors.red,
Colors.green
],).createShader(bounds);
},
child: Text("要做神仙,驾鹤飞天。点石成金,妙不可言。"),
)
要注意的是blendMode
是着色模式,如果你需要为文字着色,那么就选择BlendMode.srcATop
,shaderCallback
则是着色蒙版,比如这里回调函数传回的bounds
就是子ShaderMask
包裹子组件的矩形区域。
重点在于这个shaderCallback
,我们创建了一个线性渐变LinearGradient
,默认情况下它就是垂直居中且自左向右渐变的,通过createShader
我们就创建了一个以bounds
为区域且带有渐变的蒙版,最后应用到组件上就是这样的啦。
设置渐变起始位置
从前面的gif动画示例来看,我们需要调整渐变的起始位置,在这里我们就相当于修改蒙版区域了,这里我们使用Rect.fromLTW
来创建渐变区域,例如:
LinearGradient(colors: [
Colors.red,
Colors.green
],).createShader(Rect.fromLTWH(-bounds.width, 0.0, bounds.width, bounds.height));
我们修改下刚刚的代码,我们自定义一个矩形区域,Rect.fromLTWH
接收四个参数,分别是左上角的X左边,左上角Y坐标,矩形宽度,矩形高度,这个坐标是相当于父容器而言的。
我们这里定义左上角坐标是(-bounds.width, 0.0)
,然后这个矩形的宽高和父容器一样的,现在我们看看效果。
哦?居然是纯绿色了?我们还是回忆前面的gif。
就像是这样,相当于我们修改了渐变的开始位置,但是由于宽度没变就那么点,所以相当于渐变的结束位置也跟着改变了,上面之所以是-bounds.width,就是为了让起始位置的x坐标是在文字的更左边,这样我们通过修改蒙版左上角X的坐标就能实现文章开篇的效果啦。
如果你不熟悉Rect就看下文:
动画实现
现在我们试试看加上动画,我们假设这句歌词花费了3秒,那么我们就需要一个线性的动画,在3秒中变化起始位置~
我们需要让起始的x坐标从-bounds.width变化到bounds.width,这样才能做到前面gif的效果。
这里我们使用Tween
配合AnimationController
来完成,不过Tween的变化区间必须是正数,所以我们只能让它从0变化到bounds.width * 2
,在Rect.fromLTWH
中我们减去bounds.width
就可以了。
这里是一个完整的代码,你可以参考这个内容。
class _MyHomePageState extends State<MyHomePage>
with TickerProviderStateMixin {
late Animation<double> _animation;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 3000));
_animation = Tween<double>(
begin: 0,
end: 0, // 使用 0 到 1 的范围
).animate(_controller);
_controller.forward();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedBuilder(animation: _animation, builder: (BuildContext context, Widget? child) {
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (Rect bounds) {
_animation = Tween<double>(
begin: 0,
end: bounds.width * 2, // 重点留意
).animate(_controller);
return const LinearGradient(colors: [
Colors.red,
Colors.green
],).createShader(
// 重点留意
Rect.fromLTWH((_animation.value) - bounds.width, 0.0, bounds.width, bounds.height));
},
child: Text("要做神仙,驾鹤飞天。点石成金,妙不可言。"),
);
}, )
),
);
}
}
我们需要重点关注的是shaderCallback函数中对_animation
的赋值,因为我们设置动画的结束值是这个文本的宽度,但是我们其实是没办法在外面直接拿到的,这个时候大家可能想用GobalKey
,Buildr
的context来拿,但是GobalKey是昂贵的,我们看看有没有其他方案,我突然想到bounds不就是有文本控件的宽度吗?
那我们直接写在shaderCallback回调里,设置_animation
,不过由于AnimatedBuilder
在BlendMode
的外面,我们必须得在init里先初始化它,不然下面就出错啦。
这是我目前的方案了,但是现在仍然有问题,由于动画更新,会导致_animation
的赋值一直执行,也许可以考虑下其他的方案或者加个标志变量进行限制?其他佬要是有其他想法还请在评论区告诉我。
现在,我们看看效果如何:
可能有人觉得这个渐变让正在播放的字不太明显,对比太弱了,尽管看上去过渡很平滑,接下来我们看看怎么优化。
🦄效果进阶
让我们看看这个,现在,我们将渐变蒙版的宽度调小一些,小到几乎看不见渐变的部分,这样我们发现两边都是纯色,这样的对比是相当明显的。
这样的效果给到歌词上就有相当明显的对比啦。
在修改动画之前我们先理清楚这次效果会导致什么改变:
- 起始位置变化了,不再是 -bounds.width 而是 -1
- 结束位置变化了,不再是 bounds.width * 2 而是 bounds.width + 1
- 渐变蒙版宽度变了,不再是 bounds.width 而是 1
不知道大家发现规律了吗?如果你希望修改渐变蒙版的范围,那么就得修改起始位置,起始位置和蒙板宽度是相反数关系,由于起始位置在文字以外,因此结束时得补上这个宽度,天呐,我感觉好久没用过这个词语了!😭😭😭
不过你仍然可以让它是0开始,bounds.width结束,这也不会有问题。
最终的效果就是这样啦,看上去效果是挺不错的,但又缺少了前面渐变的那种感觉,这个就看大家选择啦。
_animation = Tween<double>(
begin: 0,
end: bounds.width,
).animate(_controller);
Rect.fromLTWH(_animation.value - 1, 0.0, 1, bounds.height)
这个是我们刚才代码的调整了,你可以试试看,让这个渐变的宽度再提升一些,比如20,那么在歌词的交界处也会有淡淡的渐变效果。
单字上色
对于单子上色而言,我们就需要额外的操作动画控制器了,就像是下面这样,我们先定义8个字,前四个字每个字都是500毫秒,后四个字每个字100毫秒,作为对照。
接下来我们需要修改一下之前写的代码,将每个字的动画时间拆开,但是渲染进度是连贯的:
ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (Rect bounds) {
if (_animation.isCompleted) {
if (count < lycs.length) {
_controller.dispose();
_controller = AnimationController(
duration: Duration(
milliseconds: lycs.elementAt(count)['duration'] as int),
vsync: this,
);
_animation = Tween(
begin: (bounds.width / lycs.length) * count,
end: (bounds.width / lycs.length) * (count + 1),
).animate(_controller);
_animation.addListener(() {
setState(() {});
});
count++;
_controller.forward();
}
}
return const LinearGradient(
colors: [Colors.red, Colors.green],
).createShader(
Rect.fromLTWH(_animation.value, 0.0, 1, bounds.height));
},
child: Text("要做神仙驾鹤飞天"),
)
这里我们需要监听动画是否播放完了,假如播放完成的话,就判断是不是播放到了最后一个字,如果不是,那么我们就销毁之前的动画控制器。
然后重新设置动画控制器,lycs.elementAt(count)['duration']
更新动画播放器的时间,相当于控制了每个字的播放时间。
现在起始的蒙版位置从(bounds.width / lycs.length) * count
变化到(bounds.width / lycs.length) * (count + 1)
,蒙版宽度仍然是1,这样做的目的是确保渐变的进度是连贯起来的,我们确定每一个字的宽度,乘以目前上色字的索引就可以做到啦。
这里我们必须使用setState,否则动画控制器的变动无法被Flutter感知到。
此外,我们实际上是改变了控制器和动画对象本身的,我们每次都是new了一个新的对象进去,希望读者了解这点。
我们看看效果:
🎢进度管控
现在,我们来看一点点逻辑的问题,事实上,如果我们拖动播放进度,歌词也需要改变,而此时歌词的绘制进度需要进行控制,就像是现在的播放器软件那样。
我们这里的例子是针对于一整句歌词而言的,因此我们得明确这一句的歌词内容(lyric
),我们的动画是匀速的,因此我们需要知道这句歌词经过的时间(totalTime
),最后为了我们外部可以控制动画的进度,我们需要外部传入动画控制器(animationController
),这样我们就可以写出下面的组件。
class SingleLineLyric extends StatefulWidget {
final String lyric;
final int totalTime;
final AnimationController? animationController;
const SingleLineLyric({
Key? key,
required this.lyric,
required this.totalTime,
this.animationController,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _SingleLineLyric();
}
现在,我们看看如何确定animationController
的动画进度,在播放器播放过程中,我们已知这些内容:
- 正在播放的歌词内容
- 已经播放的时间
- 歌词出现和结束时间
- 歌词总时间
那么这对于我们而言就相当好办了,我们需要在进度变动的回调函数里控制动画。
歌词结束时间 - 当前播放过的时间 = 歌词已经经过的时间
歌词已经经过的时间 / 歌词总时间 = 歌词已经经过的时间占歌词总时间比例
歌词已经经过的时间占歌词总时间比例 = 动画已经播放的进度
这样我们设置最后的结果给动画控制器就OK啦,当我们切换播放进度,歌词的渐变进度也会变化。
这是我的例子,其中also参考上篇文章,这是模仿了Kotlin的内置函数。
由于篇幅问题,这里就不过多考虑其他问题了,回头我们再详细说进度管控。
😱遗留问题
事实上,本文只是带大家实现了这个歌词的效果,但是并没有考虑到代码在性能上的影响,我们在代码里做了一步危险的行为,那就是在shaderCallback
中设置_animation
。要知道,动画的值一旦更新shaderCallback
就会被回调,然而动画的更新肯定是很快的,我不确定这是不是会出现问题,我不太懂这块,特别是在界面刷新上,希望有佬可以解答。
🥳文末
呼~终于结束啦,这是一篇长文,完整阅读不太轻松,但很高兴你读到这里了,现在让我们长松一口气!
希望这篇文章的内容能给大家一些思考,如果文章有错误的地方请在评论区告诉我。
参考文章
转载自:https://juejin.cn/post/7367620233140207627