Android自定義View-簡約風歌詞控件

前言

最近重構了之前的音樂播放器,添加了許多功能,比如歌詞,下載功能等。這篇文章就讓我們聊聊歌詞控件的實現(歌詞控件也已經開源,地址也在文章底部),先上效果圖,如果感覺海星,就繼續瞧下去!

在這裏插入圖片描述

看到這裏,估計你對這個控件還有點感興趣的吧,那接下來就讓我們來瞧瞧實現這個歌詞控件需要做些什麼!(如果想直接使用就直接點擊文末中的開源庫地址,裏面會有添加依賴庫的說明)

一、 歌詞解析

首先,我們得知道正常的歌詞格式是怎樣的,大概是長這個樣子:

[ti:喜歡你]
[ar:.]
[al:]
[by:]
[offset:0]
[00:00.10]喜歡你 - G.E.M. 鄧紫棋 (Gem Tang)
[00:00.20]詞:黃家駒
[00:00.30]曲:黃家駒
[00:00.40]編曲:Lupo Groinig
[00:00.50]
[00:12.65]細雨帶風溼透黃昏的街道
[00:18.61]抹去雨水雙眼無故地仰望
[00:24.04]望向孤單的晚燈
[00:26.91]
[00:27.44]是那傷感的記憶
[00:30.52]
[00:34.12]再次泛起心裏無數的思念
[00:39.28]
[00:40.10]以往片刻歡笑仍掛在臉上
[00:45.49]願你此刻可會知
[00:48.23]
[00:48.95]是我衷心的說聲
[00:53.06]
[00:54.35]喜歡你 那雙眼動人
[00:59.35]
[01:00.10]笑聲更迷人
[01:02.37]
[01:03.15]願再可 輕撫你
[01:08.56]
[01:09.35]那可愛面容
[01:12.40]挽手說夢話
[01:14.78]
[01:15.48]像昨天 你共我
[01:20.84]
[01:26.32]滿帶理想的我曾經多衝動
[01:32.45]屢怨與她相愛難有自由
[01:37.82]願你此刻可會知
[01:40.40]
[01:41.25]是我衷心的說聲
[01:44.81]
[01:46.39]喜歡你 那雙眼動人
[01:51.72]
[01:52.42]笑聲更迷人
[01:54.75]
[01:55.48]願再可 輕撫你
[02:00.93]
[02:01.68]那可愛面容
[02:03.99]
[02:04.73]挽手說夢話
[02:07.13]
[02:07.82]像昨天 你共我
[02:14.53]
[02:25.54]每晚夜裏自我獨行
[02:29.30]隨處蕩 多冰冷
[02:35.40]
[02:37.83]以往爲了自我掙扎
[02:41.62]從不知 她的痛苦
[02:52.02]
[02:54.11]喜歡你 那雙眼動人
[03:00.13]笑聲更迷人
[03:02.38]
[03:03.14]願再可 輕撫你
[03:08.77]
[03:09.33]那可愛面容
[03:11.71]
[03:12.41]挽手說夢話
[03:14.61]
[03:15.45]像昨天 你共我

從上面可以看出這種格式前面是開始時間,從左往右一一對應分,秒,毫秒,後面就是歌詞。所以我們要創建一個實體類來保存每一句的歌詞信息。

1.歌詞實體類LrcBean

public class LrcBean {
    private String lrc;//歌詞
    private long start;//開始時間
    private long end;//結束時間

    public String getLrc() {
        return lrc;
    }

    public void setLrc(String lrc) {
        this.lrc = lrc;
    }

    public long getStart() {
        return start;
    }

    public void setStart(long start) {
        this.start = start;
    }

    public long getEnd() {
        return end;
    }

    public void setEnd(long end) {
        this.end = end;
    }
}

每句歌詞,我們需要開始時間,結束時間和歌詞這些信息,那麼你就會有疑問了?上面提到的歌詞格式好像只有歌詞開始時間,那我們怎麼知道結束時間呢?其實很簡單,這一句歌詞的開始時間就是上一句歌詞的結束時間。有了歌詞實體類,我們就得開始對歌詞進行解析了!

2. 解析歌詞工具類LrcUtil

public class LrcUtil {

    /**
     * 解析歌詞,將字符串歌詞封裝成LrcBean的集合
     * @param lrcStr 字符串的歌詞,歌詞有固定的格式,一般爲
     * [ti:喜歡你]
     * [ar:.]
     * [al:]
     * [by:]
     * [offset:0]
     * [00:00.10]喜歡你 - G.E.M. 鄧紫棋 (Gem Tang)
     * [00:00.20]詞:黃家駒
     * [00:00.30]曲:黃家駒
     * [00:00.40]編曲:Lupo Groinig
     * @return 歌詞集合
     */
    public static List<LrcBean> parseStr2List(String lrcStr){
        List<LrcBean> res = new ArrayList<>();
        //根據轉行字符對字符串進行分割
        String[] subLrc = lrcStr.split("\n");
        //跳過前四行,從第五行開始,因爲前四行的歌詞我們並不需要
        for (int i = 5; i < subLrc.length; i++) {
            String lineLrc = subLrc[i];
            //[00:00.10]喜歡你 - G.E.M. 鄧紫棋 (Gem Tang)
            String min = lineLrc.substring(lineLrc.indexOf("[")+1,lineLrc.indexOf("[")+3);
            String sec = lineLrc.substring(lineLrc.indexOf(":")+1,lineLrc.indexOf(":")+3);
            String mills = lineLrc.substring(lineLrc.indexOf(".")+1,lineLrc.indexOf(".")+3);
            //進制轉化,轉化成毫秒形式的時間
            long startTime = getTime(min,sec,mills);
            //歌詞
            String lrcText = lineLrc.substring(lineLrc.indexOf("]")+1);
            //有可能是某個時間段是沒有歌詞,則跳過下面
            if(lrcText.equals("")) continue;
            //在第一句歌詞中有可能是很長的,我們只截取一部分,即歌曲加演唱者
            //比如 光年之外 (《太空旅客(Passengers)》電影中國區主題曲) - G.E.M. 鄧紫棋 (Gem Tang)
            if (i == 5) {
                int lineIndex = lrcText.indexOf("-");
                int first = lrcText.indexOf("(");
                if(first<lineIndex&&first!=-1){
                    lrcText = lrcText.substring(0,first)+lrcText.substring(lineIndex);
                }
                LrcBean lrcBean = new LrcBean();
                lrcBean.setStart(startTime);
                lrcBean.setLrc(lrcText);
                res.add(lrcBean);
                continue;
            }
            //添加到歌詞集合中
            LrcBean lrcBean = new LrcBean();
            lrcBean.setStart(startTime);
            lrcBean.setLrc(lrcText);
            res.add(lrcBean);
            //如果是最後一句歌詞,其結束時間是不知道的,我們將人爲的設置爲開始時間加上100s
            if(i == subLrc.length-1){
                res.get(res.size()-1).setEnd(startTime+100000);
            }else if(res.size()>1){
                //當集合數目大於1時,這句的歌詞的開始時間就是上一句歌詞的結束時間
                res.get(res.size()-2).setEnd(startTime);
            }

        }
        return res;
    }

    /**
     *  根據時分秒獲得總時間
     * @param min 分鐘
     * @param sec 秒
     * @param mills 毫秒
     * @return 總時間
     */
    private static long getTime(String min,String sec,String mills){
        return Long.valueOf(min)*60*1000+Long.valueOf(sec)*1000+Long.valueOf(mills);
    }
}

相信上面的代碼和註釋已經將這個歌詞解析解釋的挺明白了,需要注意的是上面對i=5,也就是歌詞真正開始的第一句做了特殊處理,因爲i=5這句有可能是很長的,假設i=5是“光年之外 (《太空旅客(Passengers)》電影中國區主題曲) - G.E.M. 鄧紫棋 (Gem Tang)”這句歌詞,如果我們不做特殊處理,在後面繪製的時候,就會發現這句歌詞會超過屏幕大小,很影響美觀,所以我們只截取歌曲名和演唱者,有些說明直接省略掉了。解析好了歌詞,接下來就是重頭戲-歌詞繪製!

二、歌詞繪製

歌詞繪製就涉及到了自定義View的知識,所以還未接觸自定義View的小夥伴需要先去看看自定View的基礎知識。歌詞繪製的主要工作主要由下面幾部分構成:

  • 爲歌詞控件設置自定義屬性,在構造方法中獲取並設置自定義屬性的默認值
  • 初始化兩支畫筆。分別是歌詞普通畫筆,歌詞高亮畫筆。
  • 獲取當前播放歌詞的位置
  • 畫歌詞,根據當前播放歌詞的位置來決定用哪支畫筆畫
  • 歌詞隨歌曲播放同步滑動
  • 重新繪製

1.設置自定View屬性,在代碼中設置默認值

在res文件中的values中新建一個attrs.xml文件,然後定義歌詞的自定義View屬性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="LrcView">
        <attr name="highLineTextColor" format="color|reference|integer"/>
        <attr name="lrcTextColor" format="color|reference|integer"/>
        <attr name="lineSpacing" format="dimension"/>
        <attr name="textSize" format="dimension"/>
    </declare-styleable>
</resources>

這裏只自定義了歌詞顏色,歌詞高亮顏色,歌詞大小,歌詞行間距的屬性,可根據自己需要自行添加。

然後在Java代碼中,設置默認值。

    private int lrcTextColor;//歌詞顏色
    private int highLineTextColor;//當前歌詞顏色
    private int width, height;//屏幕寬高
    private int lineSpacing;//行間距
    private int textSize;//字體大小

    public LrcView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.LrcView);
        lrcTextColor = ta.getColor(R.styleable.LrcView_lrcTextColor, Color.GRAY);
        highLineTextColor = ta.getColor(R.styleable.LrcView_highLineTextColor, Color.BLUE);
        float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
        float scale = context.getResources().getDisplayMetrics().density;
        //默認字體大小爲16sp
        textSize = ta.getDimensionPixelSize(R.styleable.LrcView_textSize, (int) (16 * fontScale));
        //默認行間距爲30dp
        lineSpacing = ta.getDimensionPixelSize(R.styleable.LrcView_lineSpacing, (int) (30 * scale));
        //回收
        ta.recycle();
    }

2. 初始化兩支畫筆

    private void init() {
        //初始化歌詞畫筆
        dPaint = new Paint();
        dPaint.setStyle(Paint.Style.FILL);//填滿
        dPaint.setAntiAlias(true);//抗鋸齒
        dPaint.setColor(lrcTextColor);//畫筆顏色
        dPaint.setTextSize(textSize);//歌詞大小
        dPaint.setTextAlign(Paint.Align.CENTER);//文字居中

        //初始化當前歌詞畫筆
        hPaint = new Paint();
        hPaint.setStyle(Paint.Style.FILL);
        hPaint.setAntiAlias(true);
        hPaint.setColor(highLineTextColor);
        hPaint.setTextSize(textSize);
        hPaint.setTextAlign(Paint.Align.CENTER);
    }

我們把初始化的方法放到了構造方法中,這樣就可以避免在重繪時再次初始化。另外由於我們把init方法只放到了第三個構造方法中,所以在上面兩個構造方法需要將super改成this,這樣就能保證哪個構造方法都能執行init方法

	public LrcView(Context context) {
        this(context, null);
    }

    public LrcView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public LrcView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.LrcView);
        ......
        //回收
        ta.recycle();
        init();
    }

3. 重複執行onDraw方法

因爲後面的步驟都是在onDraw方法中執行的,所以我們先貼出onDraw方法中的代碼

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        getMeasuredWidthAndHeight();//得到測量後的寬高
        getCurrentPosition();//得到當前歌詞的位置
        drawLrc(canvas);//畫歌詞
        scrollLrc();//歌詞滑動
        postInvalidateDelayed(100);//延遲0.1s刷新
    }

1.獲得控件的測量後的寬高

    private int width, height;//屏幕寬高
	private void getMeasuredWidthAndHeight(){
        if (width == 0 || height == 0) {
            width = getMeasuredWidth();
            height = getMeasuredHeight();
        }
    }

爲什麼要獲得控件的寬高呢?因爲在下面我們需要畫歌詞,畫歌詞時需要畫的位置,這時候就需要用到控件的寬高了。

2. 得到當前歌詞的位置

    private List<LrcBean> lrcBeanList;//歌詞集合
    private int currentPosition;//當前歌詞的位置
    private MediaPlayer player;//當前的播放器


    private void getCurrentPosition() {
        int curTime = player.getCurrentPosition();
        //如果當前的時間大於10分鐘,證明歌曲未播放,則當前位置應該爲0
        if (curTime < lrcBeanList.get(0).getStart()||curTime>10*60*1000) {
            currentPosition = 0;
            return;
        } else if (curTime > lrcBeanList.get(lrcBeanList.size() - 1).getStart()) {
            currentPosition = lrcBeanList.size() - 1;
            return;
        }
        for (int i = 0; i < lrcBeanList.size(); i++) {
            if (curTime >= lrcBeanList.get(i).getStart() && curTime <= lrcBeanList.get(i).getEnd()) {
                currentPosition = i;
            }
        }
    }

我們根據當前播放的歌曲時間來遍歷歌詞集合,從而判斷當前播放的歌詞的位置。細心的你可能會發現在currentPosition = 0中有個curTime>10 * 60 *1000的判斷,這是因爲在實際使用中發現當player還未播放時,這時候得到的curTime會很大,所以纔有了這個判斷(因爲正常的歌曲不會超過10分鐘)。

在這個方法我們會發現出現了歌詞集合和播放器,你可能會感到困惑,這些不是還沒賦值嗎?困惑就對了,所以我們需要提供外部方法來給外部傳給歌詞控件歌詞集合和播放器。

    //將歌詞集合傳給到這個自定義View中
    public LrcView setLrc(String lrc) {
        lrcBeanList = LrcUtil.parseStr2List(lrc);
        return this;
    }

    //傳遞mediaPlayer給自定義View中
    public LrcView setPlayer(MediaPlayer player) {
        this.player = player;
        return this;
    }

外部方法中setLrc的參數必須是前面提到的標準歌詞格式的字符串形式,這樣我們就能利用上文的解析工具類LrcUtil中的解析方法將字符串解析成歌詞集合。

3. 畫歌詞

    private void drawLrc(Canvas canvas) {
        for (int i = 0; i < lrcBeanList.size(); i++) {
            if (currentPosition == i) {//如果是當前的歌詞就用高亮的畫筆畫
                canvas.drawText(lrcBeanList.get(i).getLrc(), width / 2, height / 2 + i * lineSpacing, hPaint);
            } else {
                canvas.drawText(lrcBeanList.get(i).getLrc(), width / 2, height / 2 + i * lineSpacing, dPaint);
            }
        }
    }

知道了當前歌詞的位置就很容易畫歌詞了。遍歷歌詞集合,如果是當前歌詞,則用高亮的畫筆畫,其它歌詞就用普通畫筆畫。這裏需注意的是兩支畫筆畫的位置公式都是一樣的,座標位置爲x=寬的一半,y=高的一半+當前位置*行間距。隨着當前位置的變化,就能畫出上下句歌詞來。所以其實繪製出來後你會發現歌詞是從控件的正中央開始繪製的,這是爲了方便與下面歌詞同步滑動功能配合。

4. 歌詞同步滑動

    //歌詞滑動
    private void scrollLrc() {
        //下一句歌詞的開始時間
        long startTime = lrcBeanList.get(currentPosition).getStart();
        long currentTime = player.getCurrentPosition();

        //判斷是否換行,在0.5內完成滑動,即實現彈性滑動
        float y = (currentTime - startTime) > 500 ? currentPosition * lineSpacing : lastPosition * lineSpacing + (currentPosition - lastPosition) * lineSpacing * ((currentTime - startTime) / 500f);
        scrollTo(0,(int)y);
        if (getScrollY() == currentPosition * lineSpacing) {
            lastPosition = currentPosition;
        }
    }

如果不實現彈性滑動的話,只要判斷當前播放歌曲的時間是否大於當前位置歌詞的結束時間,然後進行scrollTo(0,(int)currentPosition * lineSpacing)滑動即可。但是爲了實現彈性滑動,我們需要將一次滑動分成若干次小的滑動並在一個時間段內完成,所以我們動態設置y的值,由於不斷重繪,就能實現在0.5秒內完成View的滑動,這樣就能實現歌詞同步彈性滑動。

500其實就是0.5s,因爲在這裏currentTime和startTime的單位都是ms

        float y = (currentTime - startTime) > 500 ? currentPosition * lineSpacing : lastPosition * lineSpacing + (currentPosition - lastPosition) * lineSpacing * ((currentTime - startTime) / 500f);

5.不斷重繪

通過不斷重繪才能實現歌詞同步滑動,這裏每隔0.1s進行重繪

postInvalidateDelayed(100);//延遲0.1s刷新

你以爲這樣就結束了嗎?其實還沒有,答案下文揭曉!

三 、使用

然後我們興高采烈的在xml中,引用這個自定義View

LrcView前面的名稱爲你建這個類的完整包名

    <com.example.library.view.LrcView
        android:id="@+id/lrcView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:lineSpacing="40dp"
        app:textSize="18sp"
        app:lrcTextColor="@color/colorPrimary"
        app:highLineTextColor="@color/highTextColor"
        />

在Java代碼中給這個自定義View傳入標準歌詞字符串和播放器。

lrcView.setLrc(lrc).setPlayer(player);

點擊運行,滿心期待自己的成果,接着你就會一臉懵逼,what?怎麼是一片空白,什麼也沒有!其實這時候你重新理一下上面歌詞繪製的流程,就會發現問題所在。首先我們的自定義View控件引用到佈局中時是先執行onDraw方法的,所以當你調用setLrc和setPlayer方法後,是不會再重新調用onDraw方法的,等於你並沒有傳入歌詞字符串和播放器,所以當然會顯示一片空白

解決方法:我們在剛纔自定義View歌詞控件中添加一個外部方法來調用onDraw,剛好這個invalidate()就能夠重新調用onDraw方法

    public LrcView draw() {
        currentPosition = 0;
        lastPosition = 0;
        invalidate();
        return this;
    }

然後我們在主代碼中,在調用setLrc和setPlayer後還得調用draw方法

lrcView.setLrc(lrc).setPlayer(player).draw();

這樣我們節約風的歌詞控件就大功告成了。

相關源碼地址

如果覺得不錯的話,歡迎大家來star!

歌詞控件源碼地址(點進去有添加該依賴庫說明)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章