本系列可能會伴隨你們很長時間,這裏我會從0開始搭建一個「網易雲音樂」的APP出來。git
下面是該APP 功能的思惟導圖:github
前期回顧:canvas
本篇爲第六篇,在這裏咱們會搭建歌詞頁面的邏輯。微信
沒錯,首先仍是咱們的老套路,確認需求。markdown
一個歌詞控件須要什麼?ide
歌詞的功能實際上是真的很多,並且我如今也沒有完成,這一節主要就來說前三個。動畫
首先最重要的就是展現歌詞,歌詞應該怎麼展現?ui
咱們先來看看官方版的網易雲:this
開始的時候歌詞從屏幕中心開始展現,隨着音樂的播放,慢慢的上移。spa
咱們想一下,什麼控件能讓文字從中間開始顯示?ListView
ScrollView
??
好像都不行,既然不行,那咱們就本身畫!
畫以前應該先了解一下歌詞的組成。
首先咱們先看一個歌詞文件:
[ti:一我的的北京]
[ar:好妹妹樂隊]
[al:南北]
[by:]
[offset:0]
[00:00.10]一我的的北京 - 好妹妹樂隊
[00:00.20]詞:秦昊
[00:00.30]曲:秦昊
[00:00.40]
[00:30.16]你有多久沒有看到 滿天的繁星
[00:37.34]城市夜晚虛僞的光明 遮住你的眼睛
[00:44.40]連週末的電影 也變得再也不有趣
[00:51.71]疲憊的日子裏 有太多的問題
[00:59.21]
[01:00.96]你有多久單身一人 再也不去旅行
[01:08.20]習慣下班回到家裏 冷冰冰的空氣
[01:15.58]愛情這東西 你已經再也不有勇氣
[01:22.64]情歌有多動聽 你就有多懷疑
[01:30.60]許多人來來去去 相聚又別離
[01:38.29]也有人喝醉哭泣 在一我的的北京
[01:45.16]也許我成功失意 慢慢的老去
[01:52.76]能不能讓我留下片刻的回憶
[01:58.95]
[04:34.24]也有人匆匆逃離 這一我的的北京
[04:41.37]也許有一天咱們 一塊兒離開這裏
[04:48.87]離開了這裏 在晴朗的天氣
[04:55.08]
複製代碼
全部的歌詞的格式都是如上這樣。
爲了咱們後續的開發,咱們應該把這些信息保存起來。
咱們仍是回過頭來想一下歌詞控件的需求:要能根據時間來滾動。
那也就說明了,這個時間咱們確定是要保存下來的,因此咱們新建一個實體類:lyric.dart
class Lyric{
String lyric;
Duration startTime;
Duration endTime;
Lyric(this.lyric, {this.startTime, this.endTime});
@override
String toString() {
return 'Lyric{lyric: $lyric, startTime: $startTime, endTime: $endTime}';
}
}
複製代碼
有當前歌詞的文字、當前歌詞的起始時間、結束時間。
而後咱們寫一個方法來解析:
/// 格式化歌詞
static List<Lyric> formatLyric(String lyricStr) {
RegExp reg = RegExp(r"^\[\d{2}");
List<Lyric> result =
lyricStr.split("\n").where((r) => reg.hasMatch(r)).map((s) {
String time = s.substring(0, s.indexOf(']'));
String lyric = s.substring(s.indexOf(']') + 1);
time = s.substring(1, time.length - 1);
int hourSeparatorIndex = time.indexOf(":");
int minuteSeparatorIndex = time.indexOf(".");
return Lyric(
lyric,
startTime: Duration(
minutes: int.parse(
time.substring(0, hourSeparatorIndex),
),
seconds: int.parse(
time.substring(hourSeparatorIndex + 1, minuteSeparatorIndex)),
milliseconds: int.parse(time.substring(minuteSeparatorIndex + 1)),
),
);
}).toList();
for (int i = 0; i < result.length - 1; i++) {
result[i].endTime = result[i + 1].startTime;
}
result[result.length - 1].endTime = Duration(hours: 1);
return result;
}
複製代碼
邏輯以下:
\n
來切割字符串Lyric
類,賦值當前文字和起始時間這樣咱們就得到了一個 歌詞列表,下面就能夠來畫歌詞了。
自定義組件,咱們都知道是使用的 CustomPainter
。
如何畫文字?這裏有兩種解決方案:
TextPainter
drawParagraph
簡單一點,咱們就使用第一種方法好了,調用 TextPainter.paint()
方法,該方法須要傳入兩個參數:
肯定了繪畫方式之後,咱們就能夠動手了。
在調用 CustomPainter
的時候須要傳入一個 size,這個 size 就是控制咱們繪製區域的。
那咱們既然從中間開始,那代碼以下:
@override
void paint(Canvas canvas, Size size) {
var y = _offsetY + size.height / 2 + lyricPaints[0].height / 2;
for (int i = 0; i < lyric.length; i++) {
if (y > size.height || y < (0 - lyricPaints[i].height / 2)) {
} else {
lyricPaints[i].paint(
canvas,
Offset((size.width - lyricPaints[i].width) / 2, y),
);
}
// 計算偏移量
y += lyricPaints[i].height + ScreenUtil().setWidth(30);
}
}
複製代碼
邏輯以下:
size.height / 2 + lyricPaints[0].height / 2
這個時候就把歌詞畫出來了。
當前歌詞高亮展現?如何判斷是當前歌詞?
在上一步當中,咱們經過解析歌詞的方法,把一個歌詞的字符串解析爲一個歌詞對象列表。
歌詞對象當中含有三個屬性:
有了這些參數,咱們就好來處理了,邏輯以下:
當歌曲播放時間變化之後,經過當前播放時間來循環列表,判斷時間戳是否在某一行內,就ok了,代碼以下:
/// 查找歌詞
static int findLyricIndex(double curDuration, List<Lyric> lyrics) {
for (int i = 0; i < lyrics.length; i++) {
if (curDuration >= lyrics[i].startTime.inMilliseconds &&
curDuration <= lyrics[i].endTime.inMilliseconds) {
return i;
}
}
return 0;
}
複製代碼
這樣咱們就能夠經過當前播放時間來找到當前所在的行數了,那麼繪製歌詞的方法以下:
void paint(Canvas canvas, Size size) {
var y = _offsetY + size.height / 2 + lyricPaints[0].height / 2;
for (int i = 0; i < lyric.length; i++) {
if (y > size.height || y < (0 - lyricPaints[i].height / 2)) {
} else {
// 畫每一行歌詞
if (curLine == i) {
lyricPaints[i].text =
TextSpan(text: lyric[i].lyric, style: commonWhiteTextStyle);
lyricPaints[i].layout();
} else {
lyricPaints[i].text =
TextSpan(text: lyric[i].lyric, style: commonGrayTextStyle);
lyricPaints[i].layout();
}
lyricPaints[i].paint(
canvas,
Offset((size.width - lyricPaints[i].width) / 2, y),
);
}
// 計算偏移量
y += lyricPaints[i].height + ScreenUtil().setWidth(30);
}
}
複製代碼
前面的條件都同樣,添加了一個判斷條件:當前循環的 i 是否等於查找出來的 index,若是等於那麼則高亮顯示,若是不是,則仍是原來的顏色。
可是咱們這個時候會發現仍是不會跟着時間來變化,由於咱們沒有通知重繪。
不用着急,在下一步會說到。
跟隨當前時間滾動,說白了就是: 當前的歌詞始終要在中間展現。
怎麼樣來讓他在中間顯示?
這裏有一個細節咱們要注意:
咱們必需要重寫 shouldRepaint
方法來通知重繪,不然組件是不會本身從新繪製的。
在「繪製歌詞」那一步的時候,咱們在寫從中間開始繪製時,留了一個參數:_offsetY
。
該參數就是爲了咱們重繪用的:
@override
bool shouldRepaint(LyricWidget oldDelegate) {
return oldDelegate._offsetY != _offsetY;
}
複製代碼
判斷兩次的 _offsetY
是否一致就行了,若是不一致,就重繪。
回到開始的問題,如何讓當前歌詞始終在中間展現?
在開始咱們繪製歌詞的時候,給每一個歌詞之間都添加上了一個間距:
y += lyricPaints[i].height + ScreenUtil().setWidth(30);
那這就好計算了,咱們只須要根據當前行計算出來 當前行和第一行的偏移量就好了:
/// 計算傳入行和第一行的偏移量
double computeScrollY(int curLine){
return (lyricPaints[0].height + ScreenUtil().setWidth(30)) * (curLine + 1);
}
複製代碼
既然有了偏移量,咱們就根據計算出來的當前行和繪製中的當前行做對比,若是不一致,則更改 _offsetY,也就是觸發重繪,這樣就出現了偏移效果。
這裏也有一個小細節就是咱們的偏移量應該是個負數,由於是向上偏移。
雖然偏移了,可是這樣很是的生硬,是直接跳上去的。咱們不能就這樣妥協,上動畫!
代碼以下:
/// 開始下一行動畫
void startLineAnim(int curLine){
// 判斷當前行和 customPaint 裏的當前行是否一致,不一致才作動畫
if(_lyricWidget.curLine != curLine){
// 若是動畫控制器不是空,那麼則證實上次的動畫未完成,
// 未完成的狀況下直接 stop 當前動畫,作下一次的動畫
if(_lyricOffsetYController != null){
_lyricOffsetYController.stop();
}
// 初始化動畫控制器,切換歌詞時間爲300ms,而且添加狀態監聽,
// 若是爲 completed,則消除掉當前controller,而且置爲空。
_lyricOffsetYController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300))..addStatusListener((status){
if(status == AnimationStatus.completed){
_lyricOffsetYController.dispose();
_lyricOffsetYController = null;
}
});
// 計算出來當前行的偏移量
var end = _lyricWidget.computeScrollY(curLine) * -1;
// 起始爲當前偏移量,結束點爲計算出來的偏移量
Animation animation = Tween<double>(begin: _lyricWidget.offsetY, end: end).animate(_lyricOffsetYController);
// 添加監聽,在動畫作效果的時候給 offsetY 賦值
_lyricOffsetYController.addListener((){
_lyricWidget.offsetY = animation.value;
});
// 啓動動畫
_lyricOffsetYController.forward();
// 給 customPaint 賦值當前行
_lyricWidget.curLine = curLine;
}
}
複製代碼
邏輯在代碼中都註釋了,應該很詳細,就不贅述了。
這樣咱們歌詞大致上就完成了。
再來看一下效果:
總的來講,歌詞控件仍是比較難的,後面還有不少功能,會慢慢的補充完成。
該系列文章代碼已傳至 GitHub:github.com/wanglu1209/…
另我我的建立了一個「Flutter 交流羣」,能夠添加我我的微信 「17610912320」來入羣。