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

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

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

前期回顧:canvas

  1. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(1、建立項目、添加插件、通用代碼)
  2. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(2、Splash Page、登陸頁、發現頁)
  3. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(3、每日推薦、推薦歌單)
  4. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(4、排行榜、播放頁面)
  5. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(5、播放功能邏輯)

本篇爲第六篇,在這裏咱們會搭建歌詞頁面的邏輯。微信

0. 確認需求

沒錯,首先仍是咱們的老套路,確認需求。markdown

一個歌詞控件須要什麼?ide

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

歌詞的功能實際上是真的很多,並且我如今也沒有完成,這一節主要就來說前三個。動畫

1. 展現歌詞

首先最重要的就是展現歌詞,歌詞應該怎麼展現?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]
複製代碼

全部的歌詞的格式都是如上這樣。

  • 全部的標籤都是由 [] 包裹起來
  • "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;
  }
}
複製代碼

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

這樣咱們歌詞大致上就完成了。

再來看一下效果:

總結

總的來講,歌詞控件仍是比較難的,後面還有不少功能,會慢慢的補充完成。

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

另我我的建立了一個「Flutter 交流羣」,能夠添加我我的微信 「17610912320」來入羣。

相關文章
相關標籤/搜索