TicktockMusic 音樂播放器項(xiàng)目相關(guān)文章匯總:
- Clean Architecture 架構(gòu):http://www.reibang.com/p/15ea0fecb61d
- 開源庫封裝:http://www.reibang.com/p/1645b81dc994
- 自定義播放暫停按鈕:http://www.reibang.com/p/74f38e9b16fc
- 自定義歌詞控件:http://www.reibang.com/p/ab735509cc74
簡(jiǎn)介
之前做 TicktockMusic 音樂播放器隙畜,一個(gè)必要的需求肯定是歌詞鸟蜡,在 github 上找了幾個(gè),發(fā)現(xiàn)或多或少都有點(diǎn)不滿足需求,所以就自己動(dòng)手寫了一個(gè),本篇文章主要介紹下實(shí)現(xiàn)的原理。
先附上項(xiàng)目地址和效果圖:
地址:https://github.com/Lauzy/LyricView
效果圖:
需求
歌詞的需求我想大家都很清楚,簡(jiǎn)單的話,直接打開一個(gè)音樂播放器查看一下撇眯。我們打開后分析一下歌詞的功能:歌詞完整的顯示出來报嵌、當(dāng)前歌詞變色、可以根據(jù)時(shí)間而進(jìn)行定位熊榛、可以手動(dòng)滑動(dòng)锚国、滑動(dòng)后顯示一個(gè)指示器、點(diǎn)擊指示器播放進(jìn)度跳轉(zhuǎn)玄坦、滑動(dòng)時(shí)指示器變色等等血筑。OK,我們自己寫歌詞控件煎楣,這些功能也是必不可少的豺总,接下來就逐步分析下實(shí)現(xiàn)的過程。
實(shí)現(xiàn)
- 歌詞解析
- 歌詞顯示
- 滑動(dòng)處理
- 指示器
基本實(shí)現(xiàn)就是這幾個(gè)過程择懂,接下來一步步的分析喻喳。
歌詞解析
首先,我們?cè)诰W(wǎng)上下載一個(gè)歌詞困曙,即以 lrc 為后綴的文件表伦。比如海闊天空這首歌的歌詞,我們用記事本或者其他工具打開后就可以看到具體的歌詞內(nèi)容慷丽,如下:
[ti: 海闊天空]
[ar:黃家駒]
[al:樂與怒]
[by:mp3.50004.com]
[00:00.00]Beyond:海闊天空
[01:40.00][00:16.00]今天我寒夜里看雪飄過
[01:48.00][00:24.00]懷著冷卻了的心窩飄遠(yuǎn)方
[01:53.00][00:29.00]風(fēng)雨里追趕
...
[00:42.00]多少次迎著冷眼與嘲笑
[00:49.00]從沒有放棄過心中的理想
[00:54.00]一剎那恍惚
...
可以看到蹦哼,歌詞主要包含歌名、歌手盈魁、專輯翔怎、作者等頭元素窃诉,以及歌詞的主體內(nèi)容杨耙,我們需要處理的就是主體的歌詞內(nèi)容。首先飘痛,歌詞是一行一行的文本珊膜,其次,每行的文本都包含時(shí)間標(biāo)簽和具體的一行歌詞宣脉,我們首先將歌詞解析為一行行的數(shù)據(jù)车柠。
InputStreamReader isr = null;
BufferedReader br = null;
try {
isr = new InputStreamReader(inputStream, CHARSET);
br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
//此處的 line 即為一行行的文本
//parseLrc 方法為解析單行
List<Lrc> lrcList = parseLrc(line);
if (lrcList != null && lrcList.size() != 0) {
lrcs.addAll(lrcList);
}
}
sortLrcs(lrcs);
return lrcs;
}catch ...
解析為一行行的文字后,就需要具體的處理單行的文字了塑猖,我們可以看到竹祷,大部分歌詞包含兩種格式,即單個(gè)時(shí)間標(biāo)簽和多個(gè)時(shí)間標(biāo)簽羊苟,這里可以采用正則表達(dá)式來匹配文字塑陵,正則表達(dá)式為 (([\d{2}:\d{2}.\d{2}])+)(.*)
[01:53.00][00:29.00]風(fēng)雨里追趕 //多個(gè)時(shí)間標(biāo)簽
[00:42.00]多少次迎著冷眼與嘲笑 //單個(gè)時(shí)間標(biāo)簽
接下來根據(jù)正則表達(dá)式來解析單行歌詞
private static List<Lrc> parseLrc(String lrcLine) {
if (lrcLine.trim().isEmpty()) {
return null;
}
List<Lrc> lrcs = new ArrayList<>();
Matcher matcher = Pattern.compile(LINE_REGEX).matcher(lrcLine);
if (!matcher.matches()) {
return null;
}
String time = matcher.group(1);
String content = matcher.group(3);
Matcher timeMatcher = Pattern.compile(TIME_REGEX).matcher(time);
while (timeMatcher.find()) {
String min = timeMatcher.group(1);
String sec = timeMatcher.group(2);
String mil = timeMatcher.group(3);
Lrc lrc = new Lrc();
if (content != null && content.length() != 0) {
lrc.setTime(Long.parseLong(min) * 60 * 1000 + Long.parseLong(sec) * 1000
+ Long.parseLong(mil) * 10);
lrc.setText(content);
lrcs.add(lrc);
}
}
return lrcs;
}
這樣,第一步就完成了蜡励,歌詞解析完成后得到歌詞的數(shù)據(jù)集合令花,每個(gè)元素都包括時(shí)間和內(nèi)容阻桅。
歌詞顯示
歌詞顯示的思路就是將歌詞一行行的畫出來,我們首先假設(shè)屏幕足夠大兼都,那么只需要定位第一行歌詞的位置嫂沉,畫出來第一行歌詞,然后逐行下移一個(gè)固定的距離扮碧,再畫出下一行歌詞趟章,依次類推,整個(gè)歌詞內(nèi)容就會(huì)全部畫在畫布上了慎王。依照這個(gè)思路尤揣,我們可以先畫出來文字。
//此處為偽代碼
float y = getLrcHeight() / 2;
float x = getLrcWidth() / 2 + getPaddingLeft();
for (int i = 0; i < getLrcCount(); i++) {
if (i > 0) {
y += textHeight + mLrcLineSpaceHeight;
}
...
canvas.drawText(text, x, y, mPaint);
}
畫出來文字的思路就是這樣柬祠,首先從屏幕的中間開始北戏,然后縱坐標(biāo)每次增加文字的高度與距離之和,依次畫出來每行文字漫蛔。這樣嗜愈,假如屏幕足夠大的話,那么所有的歌詞就會(huì)從屏幕中間開始莽龟,依次向下一行行的顯示出來蠕嫁。但是,我們的屏幕不可能是無限大的毯盈。首先剃毒,假如一行歌詞很長(zhǎng)的話,canvas.drawText() 的效果會(huì)是屏幕覆蓋掉多余的 text 文字搂赋,所以當(dāng)一行文字超過我們?cè)O(shè)置的 View 最大寬度時(shí)赘阀,最理想的方法就是多余的部分換行,就像 TextView 一樣脑奠。所幸的是基公,Android 中給我們提供了方法,那就是 StaticLayout 宋欺,StaticLayout 用法很簡(jiǎn)單轰豆,我們使用它來替代 canvas.drawText(),下面是基本用法齿诞。
private void drawLrc(Canvas canvas, float x, float y, int i) {
mTextPaint.setTextSize(mLrcTextSize);
String text = mLrcData.get(i).getText();
StaticLayout staticLayout = new StaticLayout(text, mTextPaint, getLrcWidth(),
Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false);
canvas.save();
canvas.translate(x, y - staticLayout.getHeight() / 2 - mOffset);
staticLayout.draw(canvas);
canvas.restore();
}
這樣我們就能獲取想要的效果了酸休,文字一行行的排列,文字比較長(zhǎng)的話祷杈,會(huì)自動(dòng)換行到下一行斑司。但是,這樣僅僅是實(shí)現(xiàn)效果吠式,在 onDraw() 方法中陡厘,我們應(yīng)該盡量的避免新建對(duì)象抽米,以免造成界面的卡頓,而 StaticLayout 需要實(shí)例化對(duì)象糙置,所以這邊需要我們手動(dòng)優(yōu)化一下云茸。
因?yàn)槭褂?StaticLayout 后,一行文字的高度不再固定谤饭,所以 y 坐標(biāo)不再累加固定的文字高度标捺,而是上一行和下一行文字之和的一半+文字間距。代碼如下:
for (int i = 0; i < getLrcCount(); i++) {
if (i > 0) {
y += (getTextHeight(i - 1) + getTextHeight(i)) / 2 + mLrcLineSpaceHeight;
}
drawLrc(canvas, x, y, i);
}
為了避免過多的實(shí)例化揉抵,在使用 StaticLayout 時(shí)亡容,這里采用 map 進(jìn)行緩存,創(chuàng)建過對(duì)象后緩存起來冤今,后邊就不需要再繼續(xù)創(chuàng)建闺兢。
private void drawLrc(Canvas canvas, float x, float y, int i) {
String text = mLrcData.get(i).getText();
StaticLayout staticLayout = mLrcMap.get(text);
if (staticLayout == null) {
mTextPaint.setTextSize(mLrcTextSize);
staticLayout = new StaticLayout(text, mTextPaint, getLrcWidth(),
Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false);
mLrcMap.put(text, staticLayout);
}
canvas.save();
canvas.translate(x, y - staticLayout.getHeight() / 2 - mOffset);
staticLayout.draw(canvas);
canvas.restore();
}
到這里,我們已經(jīng)解決了水平方向的顯示戏罢,但是垂直方向呢屋谭,垂直方向則利用滑動(dòng)來解決,這也是歌詞的基本需求之一龟糕。
滑動(dòng)處理
歌詞的滑動(dòng)是做歌詞控件的必然要求桐磁,包括根據(jù)音樂播放的進(jìn)度進(jìn)行自動(dòng)的滑動(dòng),以及用戶主動(dòng)拖動(dòng)的滑動(dòng)讲岁,我們來逐個(gè)分析我擂。
1、根據(jù)播放進(jìn)度滾動(dòng)
音樂的播放時(shí)間進(jìn)度可以根據(jù) MediaPlayer 來獲取缓艳,在一首音樂播放的過程中校摩,播放的進(jìn)度是不斷更新的,所以就需要我們根據(jù)這個(gè)不斷更新的時(shí)間郎任,來決定歌詞滾動(dòng)的位置秧耗。
我們需要比較不斷更新的時(shí)間和每行歌詞的時(shí)間备籽,最接近或者相等時(shí)舶治,就可以視作音樂播放的進(jìn)度對(duì)應(yīng)當(dāng)前這一行歌詞,所以需要獲取播放時(shí)間對(duì)應(yīng)的歌詞行數(shù)车猬。
private int getUpdateTimeLinePosition(long time) {
int linePos = 0;
for (int i = 0; i < getLrcCount(); i++) {
Lrc lrc = mLrcData.get(i);
if (time >= lrc.getTime()) {
if (i == getLrcCount() - 1) {假如時(shí)間大于最后一行歌詞的時(shí)間霉猛,則行數(shù)為最后一行
linePos = getLrcCount() - 1;
} else if (time < mLrcData.get(i + 1).getTime()) {//否則若同時(shí)小于下一行,則行數(shù)為 i
linePos = i;
break;
}
}
}
return linePos;
}
獲取行數(shù)之后珠闰,行數(shù)變化時(shí)惜浅,就可以利用動(dòng)畫,來讓歌詞進(jìn)行滾動(dòng)伏嗜。
private void scrollToPosition(int linePosition) {
float scrollY = getItemOffsetY(linePosition);//將要滾動(dòng)的一行的偏移量
final ValueAnimator animator = ValueAnimator.ofFloat(mOffset, scrollY);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mOffset = (float) animation.getAnimatedValue();
invalidateView();
}
});
animator.setDuration(300);
animator.start();
}
此處最重要的屬性就是 mOffset 坛悉,mOffset 是為了決定歌詞偏移量而定義的一個(gè)屬性伐厌, mOffset 的取值是在原有值和目標(biāo)行的偏移量之間,由動(dòng)畫控制其變化裸影。假如向下滑動(dòng)挣轨,初始為0,則滾動(dòng)到第二行歌詞轩猩,mOffset 就是從 0 到 getItemOffsetY(1) 的過程卷扮。 getItemOffsetY(i) 就是第 i 行的偏移量。
private float getItemOffsetY(int linePosition) {
float tempY = 0;
for (int i = 1; i <= linePosition; i++) {
tempY += (getTextHeight(i - 1) + getTextHeight(i)) / 2 + mLrcLineSpaceHeight;
}
return tempY;
}
然后均践,再根據(jù)播放進(jìn)度晤锹,進(jìn)行不斷的更新。
public void updateTime(long time) {
if (isLrcEmpty()) {
return;
}
int linePosition = getUpdateTimeLinePosition(time);
if (mCurrentLine != linePosition) {
mCurrentLine = linePosition;
ViewCompat.postOnAnimation(LrcView.this, mScrollRunnable);
}
}
private Runnable mScrollRunnable = new Runnable() {
@Override
public void run() {
scrollToPosition(mCurrentLine);
}
};
到此為止彤委,我們已經(jīng)完成了歌詞的自動(dòng)滾動(dòng)功能鞭铆。
2、滑動(dòng)事件處理
僅僅有自動(dòng)滾動(dòng)是無法滿足歌詞的需求的焦影,所以我們還需要控制歌詞的滑動(dòng)事件衔彻,讓用戶可以手動(dòng)滑動(dòng)歌詞到某個(gè)位置。既然是手勢(shì)的事件偷办,那么就需要我們重寫 onTouch 方法艰额,處理不同的手勢(shì)。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (isLrcEmpty()) { //歌詞為空椒涯,則默認(rèn)事件
return super.onTouchEvent(event);
}
//速度跟蹤
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
removeCallbacks(mScrollRunnable);
if (!mOverScroller.isFinished()) {
mOverScroller.abortAnimation();
}
mLastMotionX = event.getX();
mLastMotionY = event.getY();
isUserScroll = true;
isDragging = false;
break;
case MotionEvent.ACTION_MOVE:
float moveY = event.getY() - mLastMotionY;
if (Math.abs(moveY) > mScaledTouchSlop) {
isDragging = true;
isShowTimeIndicator = isEnableShowIndicator;
}
if (isDragging) {
float maxHeight = getItemOffsetY(getLrcCount() - 1);
if (mOffset < 0 || mOffset > maxHeight) {
moveY /= 3.5f;
}
mOffset -= moveY;
mLastMotionY = event.getY();
invalidateView();
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
handleActionUp(event);
break;
}
return true;
}
簡(jiǎn)單解釋下上述代碼柄沮,先忽略掉 VelocityTracker 和 OverScroller。在 ACTION_DOWN 時(shí)废岂,記錄下 x 和 y 的坐標(biāo)祖搓;然后在 ACTION_MOVE 時(shí),若拖動(dòng)的距離大于觸發(fā)滑動(dòng)的最小值湖苞,則改變 mOffset 的值拯欧,然后刷新 View。當(dāng) mOffset < 0 或者 mOffset > maxHeight 即歌詞已經(jīng)滾動(dòng)到頂部或者底部時(shí)财骨,為了回彈的阻尼效果镐作,將 moveY 的值大幅減小。
接下來介紹下手勢(shì)抬起的事件隆箩,VelocityTracker 和 OverScroller 就是用于此處该贾,在手勢(shì)滑動(dòng)抬起時(shí),我們希望有一個(gè) fling 的效果捌臊,Android 中的 OverScroller 可以簡(jiǎn)單的實(shí)現(xiàn)這種效果杨蛋。
private void handleActionUp(MotionEvent event) {
//越界的處理
if (overScrolled() && mOffset < 0) {
scrollToPosition(0);
ViewCompat.postOnAnimationDelayed(LrcView.this, mScrollRunnable, mTouchDelay);
return;
}
if (overScrolled() && mOffset > getItemOffsetY(getLrcCount() - 1)) {
scrollToPosition(getLrcCount() - 1);
ViewCompat.postOnAnimationDelayed(LrcView.this, mScrollRunnable, mTouchDelay);
return;
}
mVelocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
float YVelocity = mVelocityTracker.getYVelocity();
float absYVelocity = Math.abs(YVelocity);
if (absYVelocity > mMinimumFlingVelocity) {
mOverScroller.fling(0, (int) mOffset, 0, (int) (-YVelocity), 0,
0, 0, (int) getItemOffsetY(getLrcCount() - 1),
0, (int) getTextHeight(0));
invalidateView();
}
releaseVelocityTracker();
if (isAutoAdjustPosition) {
ViewCompat.postOnAnimationDelayed(LrcView.this, mScrollRunnable, mTouchDelay);
}
}
當(dāng)手勢(shì)抬起時(shí),計(jì)算下當(dāng)前的手勢(shì)速度,然后利用 mOverScroller.fling() 方法逞力,在 computeScroll() 中改變 mOffset 的值即可曙寡。
@Override
public void computeScroll() {
super.computeScroll();
if (mOverScroller.computeScrollOffset()) {
mOffset = mOverScroller.getCurrY();
invalidateView();
}
}
這樣,主動(dòng)的手勢(shì)功能也已經(jīng)實(shí)現(xiàn)了寇荧。
指示器
用戶手動(dòng)滑動(dòng)歌詞的目的卵皂,很大一部分是為了滑動(dòng)后能根據(jù)歌詞來控制播放的進(jìn)度,所以指示器也是一個(gè)不可或缺的功能砚亭。當(dāng)用戶滑動(dòng)歌詞時(shí)灯变,顯示指示器,歌詞經(jīng)過指示器的位置時(shí)變色捅膘,用戶點(diǎn)擊指示器按鈕后添祸,歌詞跳轉(zhuǎn)到這個(gè)位置,播放進(jìn)度也到了這里寻仗。
首先要做的就是顯示指示器以及歌詞變色刃泌,這里就需要我們獲取歌詞在指示器的位置時(shí),歌詞的行數(shù)署尤,因?yàn)橹甘酒鳟嬙诟柙~的中間位置耙替,所以某一行歌詞的偏移量和 mOffset 的差值最小時(shí),就可以看作這一行歌詞經(jīng)過了指示器曹体。
public int getIndicatePosition() {
int pos = 0;
float min = Float.MAX_VALUE;
//itemOffset 和 mOffset 最小時(shí)俗扇,當(dāng)前的位置
for (int i = 0; i < mLrcData.size(); i++) {
float offsetY = getItemOffsetY(i);
float abs = Math.abs(offsetY - mOffset);
if (abs < min) {
min = abs;
pos = i;
}
}
return pos;
}
然后在 onDraw() 中,畫出來具體的特性箕别。
if (isShowTimeIndicator) {
mPlayDrawable.draw(canvas); // 畫出指示器的播放按鈕
long time = mLrcData.get(indicatePosition).getTime();
float timeWidth = mIndicatorPaint.measureText(LrcHelper.formatTime(time)); //獲取指示時(shí)間的文字長(zhǎng)度
mIndicatorPaint.setColor(mIndicatorLineColor);
// 畫出指示線
canvas.drawLine(mPlayRect.right + mIconLineGap, getHeight() / 2,
getWidth() - timeWidth * 1.3f, getHeight() / 2, mIndicatorPaint);
int baseX = (int) (getWidth() - timeWidth * 1.1f);
float baseline = getHeight() / 2 - (mIndicatorPaint.descent() - mIndicatorPaint.ascent()) / 2 - mIndicatorPaint.ascent();
mIndicatorPaint.setColor(mIndicatorTextColor);
//畫出指示時(shí)間文字
canvas.drawText(LrcHelper.formatTime(time), baseX, baseline, mIndicatorPaint);
}
最后铜幽,處理用戶點(diǎn)擊事件,并且將當(dāng)前行的歌詞及時(shí)間進(jìn)行回調(diào)串稀,來控制播放進(jìn)度除抛。
if (isShowTimeIndicator && mPlayRect != null && onClickPlayButton(event)) {
isShowTimeIndicator = false;
invalidateView();
if (mOnPlayIndicatorLineListener != null) {
mOnPlayIndicatorLineListener.onPlay(mLrcData.get(getIndicatePosition()).getTime(),
mLrcData.get(getIndicatePosition()).getText());
}
}
//點(diǎn)擊在按鈕范圍才響應(yīng)
private boolean onClickPlayButton(MotionEvent event) {
float left = mPlayRect.left;
float right = mPlayRect.right;
float top = mPlayRect.top;
float bottom = mPlayRect.bottom;
float x = event.getX();
float y = event.getY();
return mLastMotionX > left && mLastMotionX < right && mLastMotionY > top
&& mLastMotionY < bottom && x > left && x < right && y > top && y < bottom;
}
這樣,指示器的功能也就完成了母截。
總結(jié)
上述就是整個(gè)歌詞控件繪制的流程到忽,還有一些顏色變化等細(xì)節(jié)功能就不一一說明了,有興趣可以看一看源碼清寇。這個(gè)控件我也已經(jīng)封裝成了一個(gè)自定義 View 的庫喘漏,可以在 https://github.com/Lauzy/LyricView 這里看下具體的使用。歡迎討論颗管、歡迎 star陷遮。
參考: