原文地址 | 源碼地址 | 加入“小知”Tower協(xié)作團(tuán)隊(duì)
注:原文的代碼閱讀和拷貝起來(lái)不太方便阁苞,我已經(jīng)摘錄出來(lái)惭缰。
前言
通過(guò)自定義View便锨,實(shí)現(xiàn)的進(jìn)階版LyricView ,能夠?qū)崿F(xiàn)歌詞滑動(dòng)查看民假,當(dāng)前播放位置高亮顯示可免,滑動(dòng)到指定位置并播放等等抓于,大致和網(wǎng)易云音樂(lè)的歌詞顯示效果一樣。
歌詞文件的組成
[ti:一個(gè)人的北京]
[ar:好妹妹樂(lè)隊(duì)]
[al:南北]
[by:]
[offset:0]
[00:00.10]一個(gè)人的北京 - 好妹妹樂(lè)隊(duì)
[00:00.20]詞:秦昊
[00:00.30]曲:秦昊
[00:00.40]
[00:30.16]你有多久沒(méi)有看到 滿天的繁星
[00:37.34]城市夜晚虛偽的光明 遮住你的眼睛
......
[04:48.87]離開(kāi)了這里 在晴朗的天氣
[04:55.08]
[04:56.27]讓我擁抱你 在晴朗的天氣
歌詞文件(*.lrc)都是以一個(gè)標(biāo)準(zhǔn)來(lái)進(jìn)行制作的浇借。
[ti: 標(biāo)題
[ar: 歌手
[al: 專輯
[by: 制作
[offset: 時(shí)間偏移量
[mm:ss.ms] 歌詞信息:由 開(kāi)始時(shí)間(分:秒.毫秒)和 歌詞內(nèi)容 兩部分組成
解析歌詞文件
首先獲取*.lrc歌詞文件的二進(jìn)制流 InputStream捉撮,再又轉(zhuǎn)換成字符流(注意:轉(zhuǎn)化成字符流的時(shí)候需要選擇編碼,比如QQ音樂(lè)的歌詞文件需要用”GBK”解碼)妇垢。
- 準(zhǔn)備兩個(gè)類主要用于歌詞解析結(jié)果的緩存:LyricInfo和 LineInfo():
LyricInfo 歌詞信息:包含標(biāo)題巾遭、歌手、專輯等等
class LyricInfo {
List<LineInfo> song_lines;
String song_artist;//歌手
String song_title;//標(biāo)題
String song_album;//專輯
long song_offset;//偏移量
}
LineInfo 歌詞行信息:包含行開(kāi)始時(shí)間和歌詞行內(nèi)容
class LineInfo {
String content;//歌詞內(nèi)容
long start;//開(kāi)始時(shí)間
}
解析歌詞文件源碼:
/**
* 初始化歌詞信息
* @param inputStream 歌詞文件的流信息
* */
private void setupLyricResource(InputStream inputStream, String charsetName) {
if(inputStream != null) {
try {
LyricInfo lyricInfo = new LyricInfo();
lyricInfo.song_lines = new ArrayList<>();
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, charsetName);
BufferedReader reader = new BufferedReader(inputStreamReader);
String line = null;
while((line = reader.readLine()) != null) {
analyzeLyric(lyricInfo, line);
}
reader.close();
inputStream.close();
inputStreamReader.close();
} catch (IOException e) {
e.printStackTrace();
}
} else {
// 暫無(wú)歌詞
}
}
/**
* 逐行解析歌詞內(nèi)容
* */
private void analyzeLyric(LyricInfo lyricInfo, String line) {
int index = line.lastIndexOf("]");
if(line != null && line.startsWith("[offset:")) {
// 時(shí)間偏移量
String string = line.substring(8, index).trim();
lyricInfo.song_offset = Long.parseLong(string);
return;
}
if(line != null && line.startsWith("[ti:")) {
// title 標(biāo)題
String string = line.substring(4, index).trim();
lyricInfo.song_title = string;
return;
}
if(line != null && line.startsWith("[ar:")) {
// artist 作者
String string = line.substring(4, index).trim();
lyricInfo.song_artist = string;
return;
}
if(line != null && line.startsWith("[al:")) {
// album 所屬專輯
String string = line.substring(4, index).trim();
lyricInfo.song_album = string;
return;
}
if(line != null && line.startsWith("[by:")) {
return;
}
if(line != null && index == 9 && line.trim().length() > 10) {
// 歌詞內(nèi)容
LineInfo lineInfo = new LineInfo();
lineInfo.content = line.substring(10, line.length());
lineInfo.start = measureStartTimeMillis(line.substring(0, 10));
lyricInfo.song_lines.add(lineInfo);
}
}
/**
* 從字符串中獲得時(shí)間值
* */
private long measureStartTimeMillis(String str) {
long minute = Long.parseLong(str.substring(1, 3));
long second = Long.parseLong(str.substring(4, 6));
long millisecond = Long.parseLong(str.substring(7, 9));
return millisecond + second * 1000 + minute * 60 * 1000;
}
驗(yàn)證解析效果
完成歌詞解析闯估,接下來(lái)就是驗(yàn)證歌詞解析的一個(gè)實(shí)際效果的時(shí)候了:
File file = new File(Constant.lyricPath + "一個(gè)人的北京 - 好妹妹樂(lè)隊(duì).lrc");
if (file != null && file.exists()) {
try {
setupLyricResource(new FileInputStream(file), "GBK");
StringBuffer stringBuffer = new StringBuffer();
if(lyricInfo != null && lyricInfo.song_lines != null) {
int size = lyricInfo.song_lines.size();
for (int i = 0; i < size; i ++) {
stringBuffer.append(lyricInfo.song_lines.get(i).content + "\n");
}
text.setText(stringBuffer.toString());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
效果圖:
就這樣灼舍,一個(gè)簡(jiǎn)單的歌詞顯示功能也就實(shí)現(xiàn)了。 但是涨薪,如何才能夠讓自己寫(xiě)的音樂(lè)播放器在歌詞顯示模塊能夠顯得高大上骑素,并且能夠像很多當(dāng)前應(yīng)用市場(chǎng)上流行的音樂(lè)播放器那樣,實(shí)現(xiàn)當(dāng)前播放高亮顯示刚夺、歌詞回彈效果献丑、歌詞淡入淡出效果以及滑動(dòng)歌詞快速播放等功能呢? 請(qǐng)接著往下讀…..
上面有提及到在早些前我有用 ScrollView 嵌套 TextView 的方式實(shí)現(xiàn)過(guò)自定義 LyricView侠姑,但是创橄,由于體驗(yàn)效果和功能拓展上的不足,我并沒(méi)有公開(kāi)分享莽红。既然通過(guò) ScrollView 嵌套 TextView 的方式 不能滿足 我們的設(shè)計(jì)需求妥畏,那是不是能夠通過(guò) 自定義View 的方式實(shí)現(xiàn) LyricView?既有如 TextView 那樣的 LineHeigh(行高)安吁、LineCount(總行數(shù)) 的概念醉蚁,也有如 ScrollView 那樣的 ScrollY(Y方向的偏移量) 的概念。那是必須的柳畔,說(shuō)干就干馍管。
LyricView實(shí)現(xiàn)
解析*.lrc歌詞文件,生成歌詞集合列表薪韩,獲得行總數(shù)
上面我已經(jīng)講過(guò)解析歌詞了确沸,而在 LyricView 中,我們需要做的是將逐行解析出來(lái)的歌詞信息添加到 集合mLyricInfo 中俘陷,而 總行數(shù)mLineCount 也就等于 List集合 的大小 mLineCount = mLyricInfo.song_lines.size()罗捎。
計(jì)算歌詞單行高度,獲得歌詞繪制區(qū)域總高度
寫(xiě)過(guò) 自定義View 的朋友應(yīng)該都會(huì)知道拉盾,在 自定義View 中如果涉及文字的繪制桨菜,為了能夠精準(zhǔn)的繪制文字的位置,我們需要獲取需要繪制的文字的矩形區(qū)域捉偏,通過(guò)畫(huà)筆 Paint 的 getTextBounds(String text, int start, int end, Rect bounds)方法 則可以幫助我們輕松獲得一個(gè)需要繪制文字的一個(gè)矩形倒得。
當(dāng)然,繪制文字矩形區(qū)域的大小還與文字的大小相關(guān)夭禽,我們還可以通過(guò)畫(huà)筆 Paint 的 setTextSize(float textSize)方法 設(shè)置繪制文字大小霞掺,也就是常說(shuō)的 TextSize。
在 LyricView 中讹躯,行高可不僅僅就只是文字矩形區(qū)域的高度菩彬,行高還包括兩行之間的行間距,如下圖所示潮梯。既然歌詞總行數(shù)和歌詞單行高度我們都已取得骗灶,那么獲取歌詞繪制區(qū)域的總高度也就so easy了:
/**
* 計(jì)算行高度
* */
private void measureLineHeight() {
Rect lineBound = new Rect();
mTextPaint.getTextBounds(mDefaultHint, 0, mDefaultHint.length(), lineBound);
mLineHeight = lineBound.height() + mLineSpace;
}
計(jì)算行高度
定義 scrollY,并通過(guò)當(dāng)前歌曲播放進(jìn)度的時(shí)間戳計(jì)算 scrollY
既然 LyricView 能夠?qū)崿F(xiàn)滑動(dòng)功能秉馏,那么引入 scrollY值 記錄滑動(dòng)偏移量耙旦,并控制視圖繪制效果也就順理成章。 需要明確一點(diǎn)萝究,當(dāng)偏移量 scrollY 的值為零的時(shí)候母廷,歌詞的首行將顯示在整個(gè) LyricView 的正中間 。
我們知道每一句歌詞中都包含著開(kāi)始時(shí)間糊肤,而我們也就可以通過(guò)當(dāng)前歌曲播放進(jìn)度匹配當(dāng)前播放的行數(shù) mCurrentPlayLine琴昆,并通過(guò)當(dāng)前播放所在行,計(jì)算偏移量 scrollY 的值馆揉,控制歌詞播放滾動(dòng)和當(dāng)前播放位置的高亮顯示业舍。
for(int i = 0, size = mLineCount; i < size; i ++) {
LineInfo lineInfo = mLyricInfo.song_lines.get(i);
if(lineInfo != null && lineInfo.start > time) {
position = i;
break;
}
if(i == mLineCount - 1) {
position = mLineCount;
}
}
匹配當(dāng)前播放行數(shù) mCurrentPlayLine
/**
* Input current showing line to measure the view's current scroll Y
* @param line 當(dāng)前指定行號(hào)
* */
private float measureCurrentScrollY(int line) {
return (line - 1) * mLineHeight;
}
計(jì)算偏移量scrollY
理論基礎(chǔ)已經(jīng)實(shí)現(xiàn),初步嘗試?yán)L圖 onDraw:
for(int i = 0, size = mLineCount; i < size; i ++) {
float x = getMeasuredWidth() * 0.5f;
float y = getMeasuredHeight() * 0.5f + (i + 0.5f) * mLineHeight - 6 - mLineSpace * 0.5f - mScrollY;
if(y + mLineHeight * 0.5f < 0) {
continue;
}
if(y - mLineHeight * 0.5f > getMeasuredHeight()) {
break;
}
if(i == mCurrentPlayLine - 1) {
mTextPaint.setColor(mHighLightColor);
} else {
if(mIndicatorShow && i == mCurrentShowLine - 1) {
mTextPaint.setColor(mCurrentShowColor);
}else {
mTextPaint.setColor(mDefaultColor);
}
}
if(y > getMeasuredHeight() - mShaderWidth || y < mShaderWidth) {
if(y < mShaderWidth) {
mTextPaint.setAlpha(26 + (int) (23000.0f * y / mShaderWidth * 0.01f));
} else {
mTextPaint.setAlpha(26 + (int) (23000.0f * (getMeasuredHeight() - y) / mShaderWidth * 0.01f));
}
} else {
mTextPaint.setAlpha(255);
}
canvas.drawText(mLyricInfo.song_lines.get(i).content, x, y, mTextPaint);
}
Bingo ! 歌詞確實(shí)能夠在屏幕上繪制出來(lái)升酣,細(xì)心的朋友也許會(huì)發(fā)現(xiàn)其中的幾個(gè)特別的地方舷暮,分別是當(dāng)前播放位置高亮顯示和歌詞淡入淡出效果實(shí)現(xiàn)的代碼:
//實(shí)現(xiàn)當(dāng)前位置高亮顯示
if(i == mCurrentPlayLine - 1) {
mTextPaint.setColor(mHighLightColor);
}
//歌詞淡入淡出效果實(shí)現(xiàn)
if(y > getMeasuredHeight() - mShaderWidth || y < mShaderWidth) {
if(y < mShaderWidth) {
mTextPaint.setAlpha(26 + (int) (23000.0f * y / mShaderWidth * 0.01f));
} else {
mTextPaint.setAlpha(26 + (int) (23000.0f * (getMeasuredHeight() - y) / mShaderWidth * 0.01f));
}
} else {
mTextPaint.setAlpha(255);
}
但是,僅僅只是實(shí)現(xiàn)顯示功能噩茄,并且超出范圍的歌詞內(nèi)容還不能通過(guò)滑動(dòng)來(lái)查看下面。哈哈~ 別著急啊,騷年绩聘,坐下來(lái)和我涼茶沥割,聽(tīng)我細(xì)細(xì)道來(lái)耗啦。
重寫(xiě) onTouchEvent,實(shí)現(xiàn)歌詞滑動(dòng)查看机杜,并實(shí)現(xiàn) overScroll 回彈效果
僅僅需要實(shí)現(xiàn)滑動(dòng)視圖的功能的話帜讲,說(shuō)實(shí)話,非常簡(jiǎn)單椒拗,只需要記錄 ACTION_DOWN 時(shí)的 y值似将,并比較 ACTION_MOVE 過(guò)程中的 y值 計(jì)算兩者的差值,生成新的偏移量 scrollY蚀苛,再刷新視圖在验,就可以搞定 !
要是就這么簡(jiǎn)簡(jiǎn)單單了事的話,怎么也不符合我個(gè)人對(duì)完美設(shè)計(jì)的要求堵未。要是我們無(wú)限滑動(dòng)的話腋舌,整個(gè)歌詞內(nèi)容區(qū)域就會(huì)滑動(dòng)出我們的可視區(qū)域,也就是常說(shuō)的 overScroll兴溜,如果不加以限制將會(huì)是一種非常差的用戶體驗(yàn)侦厚。
當(dāng)然,不同的開(kāi)發(fā)對(duì)解決這個(gè)問(wèn)題有不同的方法拙徽,有些播放器的歌詞顯示控件刨沦,當(dāng)滑動(dòng)事件出現(xiàn) overScroll 時(shí),將不再視圖繼續(xù)滑動(dòng)膘怕。當(dāng)然想诅,也有當(dāng)滑動(dòng)事件出現(xiàn) overScroll 時(shí),視圖依舊能夠繼續(xù)滑動(dòng)岛心,但與正忱雌疲滑動(dòng)時(shí)有所區(qū)別,這個(gè)時(shí)候的滑動(dòng)會(huì)有一種 阻尼效果忘古,也就是實(shí)際滑動(dòng)距離和視圖的滾動(dòng)距離并不相等徘禁,而且隨著 overScroll 的值越大,阻力越大髓堪,滑動(dòng)越艱難送朱,并在用戶手指離開(kāi)屏幕后回到 overScrol l的值為零的位置。當(dāng)然干旁,我本人更喜歡后者的用戶體驗(yàn)驶沼,為了實(shí)現(xiàn)這個(gè)功能,那么就必須要在重寫(xiě) onTouchEvent 的方法中”做點(diǎn)手腳”了争群。
/**
* 手勢(shì)移動(dòng)執(zhí)行事件
* @param event
* */
private void actionMove(MotionEvent event) {
if(scrollable()) {
final VelocityTracker tracker = mVelocityTracker;
tracker.computeCurrentVelocity(1000, maximumFlingVelocity);
float scrollY = mLastScrollY + mDownY - event.getY(); // 102 -2 58 42
float value01 = scrollY - (mLineCount * mLineHeight * 0.5f); // 52 -52 8 -8
float value02 = ((Math.abs(value01) - (mLineCount * mLineHeight * 0.5f))); // 2 2 -42 -42
mScrollY = value02 > 0 ? scrollY - (measureDampingDistance(value02) * value01 / Math.abs(value01)) : scrollY; // value01 / Math.abs(value01) 控制滑動(dòng)方向
mVelocity = tracker.getYVelocity();
measureCurrentLine();
}
}
ACTION_MOVE
/**
* 計(jì)算阻尼效果的大小
* */
private final int mMaxDampingDistance = 360;
private float measureDampingDistance(float value02) {
return value02 > mMaxDampingDistance ? (mMaxDampingDistance * 0.6f + (value02 - mMaxDampingDistance) * 0.72f) : value02 * 0.6f;
}
阻尼大小計(jì)算
通過(guò)我一次一次對(duì)代碼的細(xì)化回怜,只要這么簡(jiǎn)單的兩個(gè)方法,就完成了滑動(dòng)時(shí)偏移量 scrollY 的計(jì)算换薄,包括 overScroll 和 非overScroll玉雾,是的翔试,只要這么兩個(gè)方法。
到了這一步抹凳,歌詞的顯示遏餐、滑動(dòng)查看都已經(jīng)完成伦腐。但這還沒(méi)完赢底,我是不是還說(shuō)過(guò)我的 LyricView 能夠?qū)崿F(xiàn)像網(wǎng)易云音樂(lè)歌詞顯示控件那樣的指示器效果,哈哈哈 ~ 對(duì)于我這個(gè)完美主義者而言柏蘑,這個(gè)功能必須實(shí)現(xiàn)幸冻。
歌詞指示器效果圖
實(shí)現(xiàn)歌詞指示器效果,”屌絲”蛻變”高富帥”
其實(shí)咳焚,指示器效果實(shí)現(xiàn)起來(lái)也不是很難洽损,其實(shí)指示器左側(cè)的按鈕完全可以用繪制 Bitmap 的方式其實(shí)現(xiàn),但是革半,考慮到 LyricView 的靈活性碑定,同時(shí),我們程序猿不都是能夠用代碼繪制的決不在工程中添加圖片的嘛 又官!更何況就一個(gè)簡(jiǎn)單的播放按鈕延刘,隨便畫(huà)畫(huà),哈哈 ~ 至于六敬,右側(cè)的時(shí)間指示碘赖,則是通過(guò)當(dāng)前偏移量 scrollY 的值計(jì)算得來(lái)的當(dāng)前控件正中間位置顯示歌詞的開(kāi)始時(shí)間。
/**
* 繪制左側(cè)的播放按鈕
* @param canvas
* */
private void drawPlayer(Canvas canvas) {
mBtnBound = new Rect(mDefaultMargin, (int) (getMeasuredHeight() * 0.5f - mBtnWidth * 0.5f), mBtnWidth + mDefaultMargin, (int) (getMeasuredHeight() * 0.5f + mBtnWidth * 0.5f));
Path path = new Path();
float radio = mBtnBound.width() * 0.3f;
float value = (float) Math.sqrt(Math.pow(radio, 2) - Math.pow(radio * 0.5f, 2));
path.moveTo(mBtnBound.centerX() - radio * 0.5f, mBtnBound.centerY() - value);
path.lineTo(mBtnBound.centerX() - radio * 0.5f, mBtnBound.centerY() + value);
path.lineTo(mBtnBound.centerX() + radio, mBtnBound.centerY());
path.lineTo(mBtnBound.centerX() - radio * 0.5f, mBtnBound.centerY() - value);
mBtnPaint.setAlpha(128);
canvas.drawPath(path, mBtnPaint); // 繪制播放按鈕的三角形
canvas.drawCircle(mBtnBound.centerX(), mBtnBound.centerY(), mBtnBound. width() * 0.48f, mBtnPaint); // 繪制圓環(huán)
}
繪制指示器左側(cè)播放按鈕
/**
* 繪制指示器
* @param canvas
* */
private void drawIndicator(Canvas canvas) {
mIndicatorPaint.setColor(mIndicatorColor);
mIndicatorPaint.setAlpha(128);
mIndicatorPaint.setStyle(Paint.Style.FILL);
canvas.drawText(measureCurrentTime(), getMeasuredWidth() - mTimerBound.width(), (getMeasuredHeight() + mTimerBound.height() - 6) * 0.5f, mIndicatorPaint);
Path path = new Path();
mIndicatorPaint.setStrokeWidth(2.0f);
mIndicatorPaint.setStyle(Paint.Style.STROKE);
mIndicatorPaint.setPathEffect(new DashPathEffect(new float[]{20, 10}, 0));
path.moveTo(mPlayable ? mBtnBound.right + 24 : 24 , getMeasuredHeight() * 0.5f);
path.lineTo(getMeasuredWidth() - mTimerBound.width() - mTimerBound.width() - 36, getMeasuredHeight() * 0.5f);
canvas.drawPath(path , mIndicatorPaint);
}
繪制指示器分割線和時(shí)間
既然設(shè)計(jì)播放按鈕外构,當(dāng)然播放按鈕就要實(shí)現(xiàn)點(diǎn)擊事件捌张荨:
/**
* 判斷當(dāng)前點(diǎn)擊事件是否落在播放按鈕觸摸區(qū)域范圍內(nèi)
* @param event 觸摸事件
* */
private boolean clickPlayer(MotionEvent event) {
if(mBtnBound != null && mDownX > (mBtnBound.left - mDefaultMargin) && mDownX < (mBtnBound.right + mDefaultMargin) && mDownY > (mBtnBound.top - mDefaultMargin) && mDownY < (mBtnBound.bottom + mDefaultMargin)) {
float upX = event.getX(); float upY = event.getY();
return upX > (mBtnBound.left - mDefaultMargin) && upX < (mBtnBound.right + mDefaultMargin) && upY > (mBtnBound.top - mDefaultMargin) && upY < (mBtnBound.bottom + mDefaultMargin);
}
return false;
}
播放按鈕點(diǎn)擊位置判斷
if(mIndicatorShow && clickPlayer(event)) {
if(mCurrentShowLine != mCurrentPlayLine) {
mIndicatorShow = false;
if(mClickListener != null) {
mClickListener.onPlayerClicked(
mLyricInfo.song_lines.get(mCurrentShowLine - 1).start,
mLyricInfo.song_lines.get(mCurrentShowLine - 1).content);
}
}
}
到這一步,我們的自定義 LyricView 設(shè)計(jì)介紹也就告一段落咯 审编! 當(dāng)然撼班,功能遠(yuǎn)不止這些,還有 設(shè)置字體大小垒酬、設(shè)置行間距 以及 結(jié)合速度追蹤器實(shí)現(xiàn)滑行效果 等等砰嘁。所謂”授人以魚(yú)不如授人以漁”,我想要和大家分享的是我的一個(gè)設(shè)計(jì)思路伤溉,大家可以根據(jù)需求設(shè)計(jì)不通的功能般码,因此在這里我也不做過(guò)多介紹,對(duì)小阿飛的 LyricView 感興趣的朋友可以去我的gitHub下載研究:
https://github.com/WuLiFei/LyricViewDemo
效果圖
overScroll效果展示
字體顏色設(shè)置效果展示
字體大小設(shè)置效果展示
行間距設(shè)置效果展示
指示器和播放按鈕效果展示