Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

Flutter筆記發表於2019-10-29

本系列可能會伴隨大家很長時間,這裡我會從0開始搭建一個「網易雲音樂」的APP出來。

下面是該APP 功能的思維導圖:

Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

前期回顧:

  1. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(一、建立專案、新增外掛、通用程式碼)
  2. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(二、Splash Page、登入頁、發現頁)
  3. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(三、每日推薦、推薦歌單)
  4. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(四、排行榜、播放頁面)
  5. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(五、播放功能邏輯)

本篇為第六篇,在這裡我們會搭建歌詞頁面的邏輯。

Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

0. 確認需求

沒錯,首先還是我們的老套路,確認需求。

一個歌詞控制元件需要什麼?

  1. 展示歌詞
  2. 當前歌詞高亮顯示
  3. 跟隨當前時間滾動
  4. 可以拖動
  5. 拖動後顯示時間線
  6. 可以從時間線上點選播放

歌詞的功能其實是真的不少,而且我現在也沒有完成,這一節主要就來講前三個。

1. 展示歌詞

首先最重要的就是展示歌詞,歌詞應該怎麼展示?

我們先來看看官方版的網易雲:

Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

開始的時候歌詞從螢幕中心開始展示,隨著音樂的播放,慢慢的上移。

我們想一下,什麼控制元件能讓文字從中間開始顯示?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;
}
複製程式碼

邏輯如下:

  1. 首先根據\n 來切割字串
  2. 然後用正則挑選出所有帶時間的行
  3. 迴圈列表建立 Lyric 類,賦值當前文字和起始時間
  4. 最後再迴圈一次,把下一個的起始時間賦值到當前行的結束時間中

這樣我們就獲得了一個 歌詞列表,下面就可以來畫歌詞了。

畫歌詞

自定義元件,我們都知道是使用的 CustomPainter

如何畫文字?這裡有兩種解決方案:

  1. 使用 TextPainter
  2. 使用 drawParagraph

簡單一點,我們就使用第一種方法好了,呼叫 TextPainter.paint() 方法,該方法需要傳入兩個引數:

  1. 畫布,也就是我們的 canvas
  2. 偏移量

確定了繪畫方式以後,我們就可以動手了。

在呼叫 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);
  }
}
複製程式碼

邏輯如下:

  1. 首先確定中間位置 size.height / 2 + lyricPaints[0].height / 2
  2. 然後判斷當前偏移量是否超出或小於當前的size,如果超出則不畫他們
  3. 最後增加偏移量

這個時候就把歌詞畫出來了。

2. 當前歌詞高亮展示

當前歌詞高亮展示?如何判斷是當前歌詞?

在上一步當中,我們通過解析歌詞的方法,把一個歌詞的字串解析為一個歌詞物件列表。

歌詞物件當中含有三個屬性:

  1. lyric:當前歌詞/文字
  2. startTime:當前歌詞/文字起始時間
  3. 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;
  }
}
複製程式碼

邏輯在程式碼中都註釋了,應該很詳細,就不贅述了。

這樣我們歌詞大體上就完成了。

再來看一下效果:

Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

總結

總的來說,歌詞控制元件還是比較難的,後面還有很多功能,會慢慢的補充完成。

該系列文章程式碼已傳至 GitHub:github.com/wanglu1209/…

另我個人建立了一個「Flutter 交流群」,可以新增我個人微信 「17610912320」來入群。

Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

相關文章