android歌词效果,自定义View:Android歌词控件
TicktockMusic 音樂播放器項目相關文章匯總:
簡介
之前做 TicktockMusic 音樂播放器,一個必要的需求肯定是歌詞,在 github 上找了幾個,發現或多或少都有點不滿足需求,所以就自己動手寫了一個,本篇文章主要介紹下實現的原理。
先附上項目地址和效果圖:
效果圖:
image
需求
歌詞的需求我想大家都很清楚,簡單的話,直接打開一個音樂播放器查看一下。我們打開后分析一下歌詞的功能:歌詞完整的顯示出來、當前歌詞變色、可以根據時間而進行定位、可以手動滑動、滑動后顯示一個指示器、點擊指示器播放進度跳轉、滑動時指示器變色等等。OK,我們自己寫歌詞控件,這些功能也是必不可少的,接下來就逐步分析下實現的過程。
實現
歌詞解析
歌詞顯示
滑動處理
指示器
基本實現就是這幾個過程,接下來一步步的分析。
歌詞解析
首先,我們在網上下載一個歌詞,即以 lrc 為后綴的文件。比如海闊天空這首歌的歌詞,我們用記事本或者其他工具打開后就可以看到具體的歌詞內容,如下:
[ti: 海闊天空]
[ar:黃家駒]
[al:樂與怒]
[by:mp3.50004.com]
[00:00.00]Beyond:海闊天空
[01:40.00][00:16.00]今天我寒夜里看雪飄過
[01:48.00][00:24.00]懷著冷卻了的心窩飄遠方
[01:53.00][00:29.00]風雨里追趕
...
[00:42.00]多少次迎著冷眼與嘲笑
[00:49.00]從沒有放棄過心中的理想
[00:54.00]一剎那恍惚
...
可以看到,歌詞主要包含歌名、歌手、專輯、作者等頭元素,以及歌詞的主體內容,我們需要處理的就是主體的歌詞內容。首先,歌詞是一行一行的文本,其次,每行的文本都包含時間標簽和具體的一行歌詞,我們首先將歌詞解析為一行行的數據。
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 lrcList = parseLrc(line);
if (lrcList != null && lrcList.size() != 0) {
lrcs.addAll(lrcList);
}
}
sortLrcs(lrcs);
return lrcs;
}catch ...
解析為一行行的文字后,就需要具體的處理單行的文字了,我們可以看到,大部分歌詞包含兩種格式,即單個時間標簽和多個時間標簽,這里可以采用正則表達式來匹配文字,正則表達式為 (([\d{2}:\d{2}.\d{2}])+)(.*)
[01:53.00][00:29.00]風雨里追趕 //多個時間標簽
[00:42.00]多少次迎著冷眼與嘲笑 //單個時間標簽
接下來根據正則表達式來解析單行歌詞
private static List parseLrc(String lrcLine) {
if (lrcLine.trim().isEmpty()) {
return null;
}
List 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;
}
這樣,第一步就完成了,歌詞解析完成后得到歌詞的數據集合,每個元素都包括時間和內容。
歌詞顯示
歌詞顯示的思路就是將歌詞一行行的畫出來,我們首先假設屏幕足夠大,那么只需要定位第一行歌詞的位置,畫出來第一行歌詞,然后逐行下移一個固定的距離,再畫出下一行歌詞,依次類推,整個歌詞內容就會全部畫在畫布上了。依照這個思路,我們可以先畫出來文字。
//此處為偽代碼
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);
}
畫出來文字的思路就是這樣,首先從屏幕的中間開始,然后縱坐標每次增加文字的高度與距離之和,依次畫出來每行文字。這樣,假如屏幕足夠大的話,那么所有的歌詞就會從屏幕中間開始,依次向下一行行的顯示出來。但是,我們的屏幕不可能是無限大的。首先,假如一行歌詞很長的話,canvas.drawText() 的效果會是屏幕覆蓋掉多余的 text 文字,所以當一行文字超過我們設置的 View 最大寬度時,最理想的方法就是多余的部分換行,就像 TextView 一樣。所幸的是,Android 中給我們提供了方法,那就是 StaticLayout ,StaticLayout 用法很簡單,我們使用它來替代 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();
}
這樣我們就能獲取想要的效果了,文字一行行的排列,文字比較長的話,會自動換行到下一行。但是,這樣僅僅是實現效果,在 onDraw() 方法中,我們應該盡量的避免新建對象,以免造成界面的卡頓,而 StaticLayout 需要實例化對象,所以這邊需要我們手動優化一下。
因為使用 StaticLayout 后,一行文字的高度不再固定,所以 y 坐標不再累加固定的文字高度,而是上一行和下一行文字之和的一半+文字間距。代碼如下:
for (int i = 0; i < getLrcCount(); i++) {
if (i > 0) {
y += (getTextHeight(i - 1) + getTextHeight(i)) / 2 + mLrcLineSpaceHeight;
}
drawLrc(canvas, x, y, i);
}
為了避免過多的實例化,在使用 StaticLayout 時,這里采用 map 進行緩存,創建過對象后緩存起來,后邊就不需要再繼續創建。
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();
}
到這里,我們已經解決了水平方向的顯示,但是垂直方向呢,垂直方向則利用滑動來解決,這也是歌詞的基本需求之一。
滑動處理
歌詞的滑動是做歌詞控件的必然要求,包括根據音樂播放的進度進行自動的滑動,以及用戶主動拖動的滑動,我們來逐個分析。
1、根據播放進度滾動
音樂的播放時間進度可以根據 MediaPlayer 來獲取,在一首音樂播放的過程中,播放的進度是不斷更新的,所以就需要我們根據這個不斷更新的時間,來決定歌詞滾動的位置。
我們需要比較不斷更新的時間和每行歌詞的時間,最接近或者相等時,就可以視作音樂播放的進度對應當前這一行歌詞,所以需要獲取播放時間對應的歌詞行數。
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) {假如時間大于最后一行歌詞的時間,則行數為最后一行
linePos = getLrcCount() - 1;
} else if (time < mLrcData.get(i + 1).getTime()) {//否則若同時小于下一行,則行數為 i
linePos = i;
break;
}
}
}
return linePos;
}
獲取行數之后,行數變化時,就可以利用動畫,來讓歌詞進行滾動。
private void scrollToPosition(int linePosition) {
float scrollY = getItemOffsetY(linePosition);//將要滾動的一行的偏移量
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 是為了決定歌詞偏移量而定義的一個屬性, mOffset 的取值是在原有值和目標行的偏移量之間,由動畫控制其變化。假如向下滑動,初始為0,則滾動到第二行歌詞,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;
}
然后,再根據播放進度,進行不斷的更新。
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);
}
};
到此為止,我們已經完成了歌詞的自動滾動功能。
2、滑動事件處理
僅僅有自動滾動是無法滿足歌詞的需求的,所以我們還需要控制歌詞的滑動事件,讓用戶可以手動滑動歌詞到某個位置。既然是手勢的事件,那么就需要我們重寫 onTouch 方法,處理不同的手勢。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (isLrcEmpty()) { //歌詞為空,則默認事件
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;
}
簡單解釋下上述代碼,先忽略掉 VelocityTracker 和 OverScroller。在 ACTION_DOWN 時,記錄下 x 和 y 的坐標;然后在 ACTION_MOVE 時,若拖動的距離大于觸發滑動的最小值,則改變 mOffset 的值,然后刷新 View。當 mOffset < 0 或者 mOffset > maxHeight 即歌詞已經滾動到頂部或者底部時,為了回彈的阻尼效果,將 moveY 的值大幅減小。
接下來介紹下手勢抬起的事件,VelocityTracker 和 OverScroller 就是用于此處,在手勢滑動抬起時,我們希望有一個 fling 的效果,Android 中的 OverScroller 可以簡單的實現這種效果。
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);
}
}
當手勢抬起時,計算下當前的手勢速度,然后利用 mOverScroller.fling() 方法,在 computeScroll() 中改變 mOffset 的值即可。
@Override
public void computeScroll() {
super.computeScroll();
if (mOverScroller.computeScrollOffset()) {
mOffset = mOverScroller.getCurrY();
invalidateView();
}
}
這樣,主動的手勢功能也已經實現了。
指示器
用戶手動滑動歌詞的目的,很大一部分是為了滑動后能根據歌詞來控制播放的進度,所以指示器也是一個不可或缺的功能。當用戶滑動歌詞時,顯示指示器,歌詞經過指示器的位置時變色,用戶點擊指示器按鈕后,歌詞跳轉到這個位置,播放進度也到了這里。
首先要做的就是顯示指示器以及歌詞變色,這里就需要我們獲取歌詞在指示器的位置時,歌詞的行數,因為指示器畫在歌詞的中間位置,所以某一行歌詞的偏移量和 mOffset 的差值最小時,就可以看作這一行歌詞經過了指示器。
public int getIndicatePosition() {
int pos = 0;
float min = Float.MAX_VALUE;
//itemOffset 和 mOffset 最小時,當前的位置
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)); //獲取指示時間的文字長度
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);
//畫出指示時間文字
canvas.drawText(LrcHelper.formatTime(time), baseX, baseline, mIndicatorPaint);
}
最后,處理用戶點擊事件,并且將當前行的歌詞及時間進行回調,來控制播放進度。
if (isShowTimeIndicator && mPlayRect != null && onClickPlayButton(event)) {
isShowTimeIndicator = false;
invalidateView();
if (mOnPlayIndicatorLineListener != null) {
mOnPlayIndicatorLineListener.onPlay(mLrcData.get(getIndicatePosition()).getTime(),
mLrcData.get(getIndicatePosition()).getText());
}
}
//點擊在按鈕范圍才響應
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;
}
這樣,指示器的功能也就完成了。
總結
上述就是整個歌詞控件繪制的流程,還有一些顏色變化等細節功能就不一一說明了,有興趣可以看一看源碼。這個控件我也已經封裝成了一個自定義 View 的庫,可以在 https://github.com/Lauzy/LyricView 這里看下具體的使用。歡迎討論、歡迎 star。
參考:
總結
以上是生活随笔為你收集整理的android歌词效果,自定义View:Android歌词控件的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: socket缓冲区以及阻塞模式详解
- 下一篇: TCP协议的粘包问题(数据的无边界性)