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

Flutter筆記發表於2019-11-05

本系列可能會伴隨大家很長時間,這裡我會從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(五、播放功能邏輯)
  6. Flutter實戰 | 從 0 搭建「網易雲音樂」APP(六、歌詞(一))

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

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

書接上文

我們書接上文,上文中說到歌詞控制元件的需求:

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

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

上文我們實現了前三個,那這篇文章就帶大家來實現後三個功能。

下面我們就開始。

4. 歌詞可以拖動

不知道還記不記得,上篇文章中,我們是如何繪製歌詞的:

_offsetY + size.height / 2 + lyricPaints[0].height / 2;
複製程式碼

該段程式碼就是獲取中間位置的。

其中有個 _offsetY ,在上篇文章中,我們使用它來做自動滾動效果,那在本功能中,我們就可以使用它來做拖動的效果。

直接在 CustomPaint 控制元件上套一個 GestureDetector

onVerticalDragUpdate: (e) {
  _lyricWidget.offsetY += e.delta.dy;
}
複製程式碼

然後在 onVerticalDragUpdate 中使這個 offsetY 加上偏移量就行了。

但是關於歌詞拖動這裡有個細節:不能拖動到極限(上、下)。

這裡的極限是什麼?

上極限為 _offsetY.abs() < lyricPaints[0].height + ScreenUtil().setWidth(30)

下極限為 _offsetY.abs() > (totalHeight + lyricPaints[0].height + ScreenUtil().setWidth(30))

也就是我們第一行和最後一行文字的地方。

賦值 _offsetY 方法全部程式碼如下:

set offsetY(double value) {
  // 判斷如果是在拖動狀態下
  if (isDragging) {
    // 不能小於最開始的位置
    if (_offsetY.abs() < lyricPaints[0].height + ScreenUtil().setWidth(30)) {
      _offsetY = (lyricPaints[0].height + ScreenUtil().setWidth(30)) * -1;
    } else if (_offsetY.abs() > (totalHeight + lyricPaints[0].height + ScreenUtil().setWidth(30))) {
      // 不能大於最大位置
      _offsetY = (totalHeight + lyricPaints[0].height + ScreenUtil().setWidth(30)) * -1;
    } else {
      _offsetY = value;
    }
  } else {
    _offsetY = value;
  }
  notifyListeners();
}
複製程式碼

這樣就完成了我們拖動歌詞的需求。

5. 拖拽時顯示時間線

這是相對來說比較複雜的功能,涉及到的有:

  1. 拖拽時顯示,不拖拽時不顯示
  2. 拖拽到某一行改變顏色
  3. 顯示拖拽到的那一行的起始時間
  4. 畫時間線

首先不管拖拽的東西,先來顯示這個時間線。

畫時間線

因為歌詞是使用 CustomPainter 來實現的,那時間線,我們也是,使用 CustomPainter 來實現。

首先看一下樣式:

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

可以看到,這個「時間線」是由三部分組成:

  1. 播放按鈕
  2. 一條線
  3. 當前行的時間

畫播放按鈕

播放按鈕我們使用的是 icon,如何在 CustomPainter 中畫 icon?

使用 Paragraph

// 畫 icon
final icon = Icons.play_arrow;
var builder = prefix0.ParagraphBuilder(prefix0.ParagraphStyle(
  fontFamily: icon.fontFamily,
  fontSize: ScreenUtil().setWidth(60),
))
  ..addText(String.fromCharCode(icon.codePoint));
var para = builder.build();
para.layout(prefix0.ParagraphConstraints(
  width: ScreenUtil().setWidth(60),
));
canvas.drawParagraph(
  para,
  Offset(ScreenUtil().setWidth(10),
         size.height / 2 - ScreenUtil().setWidth(60)));
複製程式碼

其實這裡是把 icon 當做字型來設定的,設定大小使用 fontSize 就好了。

畫線

線相對來說是好畫的了:

// 畫線
canvas.drawLine(
  Offset(ScreenUtil().setWidth(80),
         size.height / 2 - ScreenUtil().setWidth(30)),
  Offset(size.width - ScreenUtil().setWidth(120),
         size.height / 2 - ScreenUtil().setWidth(30)),
  linePaint);
複製程式碼

畫這一行的起始時間

這其實也沒什麼好說的,就是畫個文字,算好偏移量就行了:

draggingLineTimeTextPainter = TextPainter(
  text: TextSpan(
    text: DateUtil.formatDateMs(dragLineTime,
                                format: "mm:ss"),
    style: smallGrayTextStyle),
  textDirection: TextDirection.ltr,
);
draggingLineTimeTextPainter.layout();
draggingLineTimeTextPainter.paint(
  canvas,
  Offset(size.width - ScreenUtil().setWidth(80),
         size.height / 2 - ScreenUtil().setWidth(45)));
複製程式碼

拖拽時顯示,不拖拽時不顯示

時間線畫完了,就該來到拖拽環節,這個時候同學肯定會想到,我們剛才套了一層 GestureDetector

沒錯,那在什麼條件下顯示和不顯示?

顯示的邏輯?

我們首先想到的肯定是 onVerticalDragDown + onVerticalDragEnd,因為畢竟是在按下時顯示和抬起時消失嘛,

這就錯了,我們不應該在手指按下的時候就顯示時間線,而應該是在拖動的時候顯示時間線!

我們給 CustomPainter 一個變數:isDragging -> 是否正在拖動中。

然後在 GestureDetectoronVerticalDragUpdate 方法中做操作:

onVerticalDragUpdate: (e) {
  if (!_lyricWidget.isDragging) {
    setState(() {
      _lyricWidget.isDragging = true;
    });
  }
  _lyricWidget.offsetY += e.delta.dy;
}
複製程式碼

如果不是在拖動中,那麼則改變它的狀態。

並且在 CustomPainterpaint 方法中:

// 拖動狀態下顯示的東西
if (isDragging) {
  // 畫 icon
  xxx;

  // 畫線
  xxx;

  // 畫當前行的時間
  xxx;
}
複製程式碼

這樣就完成了我們顯示的問題,那什麼時候不顯示?

不顯示的邏輯?

我們可以通過檢視網易雲官方APP來看一下,拖動結束後大約一兩秒鐘的時間才會消失,這個時間差是為了給使用者點選時間線上的播放按鈕準備的。

那我們也來實現一下。

首先我們設定延遲消失時間是一秒,消失的動作其實就是把 isDragging 設定為 false:

dragEndFunc = () {
  if (_lyricWidget.isDragging) {
    setState(() {
      _lyricWidget.isDragging = false;
    });
  }
};
複製程式碼

這裡學過前端的同學應該都聽說過一個詞:節流與防抖

沒錯,如果這裡我們在結束拖動的一秒內,再次拖動,那麼這個延遲的方法就會再次執行,這樣肯定是有問題的,所以我們也要進行節流與防抖

如何進行防抖?

其實上一篇文章中自動滾動歌詞效果就帶了防抖,但是那個是使用的動畫,這裡我們就要使用 Timer 來進行防抖。

首先定義好方法和延遲時間:

dragEndDuration = Duration(milliseconds: 1000);

dragEndFunc = () {
  if (_lyricWidget.isDragging) {
    setState(() {
      _lyricWidget.isDragging = false;
    });
  }
};
複製程式碼

接著在拖動結束後的方法中呼叫:

void cancelDragTimer() {
  if (dragEndTimer != null) {
    if (dragEndTimer.isActive) {
      dragEndTimer.cancel();
      dragEndTimer = null;
    }
  }
  dragEndTimer = Timer(dragEndDuration, dragEndFunc);
}
複製程式碼

邏輯如下:

  1. 首先判斷該 Timer 是否為空
  2. 如果不為空則判斷是否在活躍狀態
  3. 如果都滿足條件,則取消這個 Timer 的任務,並且置為空
  4. 最後重新賦值任務

這樣就可以達到我們預期的結果:在最後一次拖動結束的一秒鐘後,把時間線消失。

拖拽到某一行改變顏色

時間線的顯示和消失,我們也搞定了,那麼現在就開始搞拖拽的效果。

拖拽到某一行改變顏色,我們怎麼知道是拖拽到了哪一行?

這還不簡單,直接使用 offsetY 來判斷就好了呀:

if (isDragging &&
    i ==
    (_offsetY / (lyricPaints[0].height + ScreenUtil().setWidth(30)))
    .abs()
    .round() - 1) {
  // 如果是拖動狀態中的當前行
  lyricPaints[i].text =
    TextSpan(text: lyric[i].lyric, style: commonWhite70TextStyle);
  lyricPaints[i].layout();
}
複製程式碼

如果 i == 正在拖動中 並且 用**當前偏移量 / 每行的偏移量 得到的值的絕對值的四捨五入的值,**那麼就代表是當前拖動中的行。(說的有點亂)

因為總長度就是用每行的偏移量加起來的,最大的偏移量也就是這麼多,所以用偏移量除以每行的偏移量就能得到我們當前拖動到的行了。

然後設定不同顏色的字型就ok了。

顯示拖拽到的那一行的起始時間

既然我們能得到當前是哪一行,那獲取這一行的起始時間也不是難事:

dragLineTime = lyric[
  (_offsetY / (lyricPaints[0].height + ScreenUtil().setWidth(30)))
  .abs()
  .round() -1]
  .startTime.inMilliseconds;
複製程式碼

到這我們所有拖拽的功能算是結束了,就剩下一個點選事件。

6. 可以從時間線上點選播放

寫這個功能的時候,上來就遇到了一個問題,怎麼樣才算點選了這個 icon???

CustomPainter 裡面也沒有給這個佈局設定點選事件的地方,wdnmd,這咋整?

苦思冥想,大不了我判斷點選的座標!

說幹我們就幹,在 onTap 中沒有返回這個座標,那我先在 onPanDown 裡試試:

onPanDown: (e){
	print(e.localPosition);
},
複製程式碼

當我執行到手機,並且點選的時候,整個人都不好了!

座標確實列印出來了,但是直接給我返回到碟片那個頁面了!!!

我竟然忘了還有這個操作!點選頁面是 「歌詞 」和 「碟片」 來回跳轉的!

這可咋整,如何才能讓他不跳轉?也就是不走父元件的 onTap() 方法。

這裡有一點,如果子元件有點選事件,並且父元件沒有設定相對應的 behavior,那麼事件是不會冒泡到父元件的。

所以,我們只需要進行相對應的設定:

onTapDown: _lyricWidget.isDragging
  ? (e) {
  if (e.localPosition.dx > 0 &&
      e.localPosition.dx < ScreenUtil().setWidth(100) &&
      e.localPosition.dy >
      _lyricWidget.canvasSize.height / 2 -
      ScreenUtil().setWidth(100) &&
      e.localPosition.dy <
      _lyricWidget.canvasSize.height / 2 +
      ScreenUtil().setWidth(100)) {
    widget.model.seekPlay(_lyricWidget.dragLineTime);
  }
}
: null,
複製程式碼

如果是在拖動狀態中,那麼設定上點選事件,如果不是的話,設定為null 就好了,這也能解釋我們上面給 isDragging 賦值的時候為什麼會 setState() ,就是因為要設定這個點選事件。

最後判斷點選的位置就ok了,也是非常簡單的。

總結

參考了很多 Android 上的歌詞控制元件,終於我們歌詞就全部結束了,歌詞的功能真的是不少,寫起來也是挺難的,判斷的東西有點多。(也可能是因為我第一次寫歌詞類的東西,比較菜)

當然還是那句話,該專案是我本人自己在工作之餘寫的,所以進度不會很快,但是會一直寫下去。

大家如果有好的建議的話,歡迎提 issue,我會在第一時間回覆。

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

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

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

相關文章