本系列可能會伴隨大家很長時間,這裡我會從0開始搭建一個「網易雲音樂」的APP出來。
下面是該APP 功能的思維導圖:
前期回顧:
本篇為第六篇,在這裡我們會搭建歌詞頁面的邏輯。
0. 確認需求
沒錯,首先還是我們的老套路,確認需求。
一個歌詞控制元件需要什麼?
- 展示歌詞
- 當前歌詞高亮顯示
- 跟隨當前時間滾動
- 可以拖動
- 拖動後顯示時間線
- 可以從時間線上點選播放
歌詞的功能其實是真的不少,而且我現在也沒有完成,這一節主要就來講前三個。
1. 展示歌詞
首先最重要的就是展示歌詞,歌詞應該怎麼展示?
我們先來看看官方版的網易雲:
開始的時候歌詞從螢幕中心開始展示,隨著音樂的播放,慢慢的上移。
我們想一下,什麼控制元件能讓文字從中間開始顯示?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]
複製程式碼
所有的歌詞的格式都是如上這樣。
- 所有的標籤都是由 [] 包裹起來
- "ti"表示標題、"ar"表示歌手、"al"表示專輯、"by"表示製作、"offset:"表示時間偏移量
- [mm:ss.ms] 是這一行歌詞的時間
為了我們後續的開發,我們應該把這些資訊儲存起來。
解析歌詞
我們還是回過頭來想一下歌詞控制元件的需求:要能根據時間來滾動。
那也就說明了,這個時間我們肯定是要儲存下來的,所以我們新建一個實體類: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()
方法,該方法需要傳入兩個引數:
- 畫布,也就是我們的 canvas
- 偏移量
確定了繪畫方式以後,我們就可以動手了。
在呼叫 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
- 然後判斷當前偏移量是否超出或小於當前的size,如果超出則不畫他們
- 最後增加偏移量
這個時候就把歌詞畫出來了。
2. 當前歌詞高亮展示
當前歌詞高亮展示?如何判斷是當前歌詞?
在上一步當中,我們通過解析歌詞的方法,把一個歌詞的字串解析為一個歌詞物件列表。
歌詞物件當中含有三個屬性:
- lyric:當前歌詞/文字
- startTime:當前歌詞/文字起始時間
- endTime:當前歌詞/文字結束時間
有了這些引數,我們就好來處理了,邏輯如下:
當歌曲播放時間變化以後,通過當前播放時間來迴圈列表,判斷時間戳是否在某一行內,就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,如果等於那麼則高亮顯示,如果不是,則還是原來的顏色。
但是我們這個時候會發現還是不會跟著時間來變化,因為我們沒有通知重繪。
不用著急,在下一步會說到。
3. 跟隨當前時間滾動
跟隨當前時間滾動,說白了就是: 當前的歌詞始終要在中間展示。
怎麼樣來讓他在中間顯示?
這裡有一個細節我們要注意:
我們必須要重寫 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」來入群。