网易云音乐是我最常用的一个软件。不仅界面美观,功能还不错(这不是打广告哈)。今天,我就来利用网易云音乐现成的歌词文件来制作一个自定义的歌词显示view。效果如下。
效果看完,下面解释撸代码的时候了。读取歌词文件
我使用的歌词文件时网易云音乐的歌词文件,结构如下截图: 可以比较明显的看出这是一个json数据。其中【00:00.00】表示的是【分:秒.毫秒】的形式。知道歌词结构后就开始解析数据了。歌词解析
先将歌词文件存放到android studio下的assets文件夹下,当然放到手机内存中也行,读得到就行。下面是读取文件的代码。try {
//读取assets文件夹下名字为86357的文件
InputStream lrycis = getResources().getAssets().open("86357");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(lrycis));
//结束标记符
boolean eof = false;
//临时保存的行内容
String line = null;
StringBuffer stringBuffer = new StringBuffer();
while (!eof){
line = bufferedReader.readLine();
if (line == null){
eof = true;
}else {
stringBuffer.append(line);
}
}
//操作完成后记得关闭输入流,释放内存
bufferedReader.close();
lrycis.close();
Gson gson = new Gson();
//将歌词装换成对应的beam类,方便获取内容
SongLyric songLyric = gson.fromJson(stringBuffer.toString(),SongLyric.class);
//每一个\n表示一行歌词,根据这个结构可以解析出所有的行
String[] lyricArray = songLyric.getLyric().split("\n");
lryList = new ArrayList<>();
for (int i=0; i<lyricArray.length; i++){
String ly = lyricArray[i];
//获取分钟
String min = lyricArray[i].substring(1,ly.indexOf(":"));
//获取秒钟
String second = lyricArray[i].substring(ly.indexOf(":")+1,ly.indexOf("."));
//获取毫秒
String minSecond = lyricArray[i].substring(ly.indexOf(".")+1,ly.indexOf("]"));
//获取歌词
String strLy = ly.substring(ly.indexOf("]")+1);
//计算歌词起点的毫秒数
int allTime = (Integer.parseInt(min)*60*1000+Integer.parseInt(second)*1000+Integer.parseInt(minSecond));
//将歌词和起点装到集合中去,用歌词不常用到的%作为分隔符
lryList.add(allTime+"%"+strLy);
}
musicLrycisView.setLys(lryList);
} catch (IOException e) {
e.printStackTrace();
Log.e("日志","错误日志:"+e.getLocalizedMessage());
}
复制代码
读取完文本后需要利用gson把文本转换成一个beam类,通过beam类获取到歌词的具体内容。为了在使用歌词比较容易获取到歌词对应的时间点,在后面统一把时间装换成毫秒了。时间的解析就是通过是【分:秒.毫】这个结构来解析的。解析完后将所有歌词保存到集合中去。读取文件属于耗时操作,放到子线程操作好点。
自定义歌词view
逻辑梳理:自定义view因具备点击快进,歌词与音频同步,选中歌词部分颜色高亮这三个功能。这三个功能实现前提是歌词可以正常显示出来(废话)。有了这四个步骤后开始构造自定义view
- 歌词正常显示
这个步骤比较容易实现。先自定义一个view,继承自AppCompatTextView。然后在类里面新建一个方法,updateTimeByIndex(int index,int type),index为歌词所在索引,后面快进,倒退及歌词自动同步音频时会用到,type表示操作类型,类型包括快进和倒退两种。定义好重写view的onSizeChanged(int w, int h, int oldw, int oldh)方法,在这里获取view的高度的一半,用于使歌词选中部分始终保持在view的中间位置。都写好后开始进入正题。讲太多没用,先给代码。
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
halfHeight = this.getHeight()/2;
Log.e("日志","高度为:"+halfHeight);
}
复制代码
/**
* 更新歌词显示,相比与上面的方法,此方法时在快进或倒退时使用的。
* @param index 当前歌词在歌词集合中的位置
*/
public void updateTimeByIndex(int index){
//当type为-1时,不允许外部赋值
if (this.type != -1){
defaultMove = halfHeight-paint.measureText("1")*3f*index;
invalidate();
}
}
复制代码
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(0,defaultMove);
//通过行数计算歌词总高度
lryHeight = lys.size() * paint.measureText("1")*3f;
//计算每行高度,测量“1”目的是获取单个字符的高度,因为单个字符占用的空间时正方形。
singleHeight = paint.measureText("1")*3f;
index = Math.abs((int)((defaultMove - halfHeight)/singleHeight));
if (index > lys.size()-1){
index = lys.size()-1;
}
for (int i=0;i<lys.size(); i++){
if (i != index){
paint.setColor(unselectLrcTextColor);
}else {
paint.setColor(selectLrcTextColor);
rect.set( (int)(getWidth()/2-paint.measureText(lys.get(i).split("%")[1].trim())),
(int)((paint.measureText("1")*(i)*3f- AppUtils.dip2px(getContext(),7))),
(int)(getWidth()/2+paint.measureText(lys.get(i).split("%")[1].trim())),
(int)(paint.measureText("1")*(i)*3f+paint.measureText("1")+AppUtils.dip2px(getContext(),7))
);
}
//getWidth()/2-paint.measureText(lys.get(i).split("%")[1].trim())/2使文字居中显示
canvas.drawText(lys.get(i).split("%")[1].trim(),getWidth()/2-paint.measureText(lys.get(i).split("%")[1].trim())/2,paint.measureText("1")*(i)*3f,paint);
}
}
复制代码
正常显示歌词直接使用for语句循环遍历一下传入的歌词集合就行。for循环完后看到的效果可能是下面的效果。
开时部分没有被移动到屏幕中间,这时候上面代码的 halfHeight就派上用场了,这个值时view高度的一般,绘制内容时可以使用这个值使整体内容下移半个view高度。
-
选中歌词高亮
这部分主要通过比较index来实现。当循环遍历的i值与计算出来的index值相同时,说明此处时歌词应该显示高亮的位置,这是改变一下颜色就可以了。代码就是上面贴出代码中onDraw方法中的for循环部分。 -
歌词与音频同步
这里的实现是在歌曲音频时间发生改变时调用一次updateTimeByIndex方法进行同步操作。updateTimeByIndex中的index实际上是和ondraw方法中的index是相同的。为什么我还要在ondraw方法中重写计算index呢?因为在拖动歌词进行快进倒退时,我只可以通过计算方式去获取index,为了两边统一,所以我直接使用的index是在ondraw方法中计算出来的index。updateTimeByIndex中的index只用于计算歌词应该下滑的距离。下面贴出外部调用updateTimeByIndex方法的代码。
public void setMusicLry(int position){
//旧的上一步歌曲时间减去现在的歌曲时间大于2秒,被认为是快进了,快进执行快进操作
int lryIndex = 0;
for (int i=0;i<lryList.size();i++){
if (position > Integer.parseInt(lryList.get(i).split("%")[0]) && i+1 > lryList.size()-1){
lryIndex = lryList.size() - 1;
break;
}else if (position > Integer.parseInt(lryList.get(i).split("%")[0]) && position < Integer.parseInt(lryList.get(i+1).split("%")[0])){
lryIndex = i;
break;
}
}
musicLrycisView.updateTimeByIndex(lryIndex); }
//设置当行当前歌词的显示
lycText.setText(lryList.get(lryIndex).split("%")[1]);
}
复制代码
position是歌曲当前的时间(单位:毫秒)。先在for循环中找出在此时间段内的歌词,然后调用updateTimeByIndex方法进行同步操作。
- 实现拖动进行倒退,快进功能。
/**
* 设置手势,上划快进,下拉倒退。
* @param event
* @return
*/
private float downY;
private long clickTime = 0;
private boolean isClick = false;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
clickTime = System.currentTimeMillis();
downY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
//活动后设置歌词为不可同步状态,直到3秒后同步设置为可同步状态。此目的是为了防止户刚滑动到某处时外部对歌词view重新赋值后当前歌词有跳转会当前歌词而不是用户滑动歌词的地方
float tempMove = defaultMove + (event.getY() - downY)/ AppUtils.dip2px(getContext(),6);
if (tempMove < halfHeight){
defaultMove = defaultMove + (event.getY() - downY)/ AppUtils.dip2px(getContext(),6);
}else if (tempMove > halfHeight){
defaultMove = halfHeight;
}else if (tempMove == halfHeight){
return false;
}
//用户手指移动5dp内可认为是点击,当然,是否是点击还得结合点击时间判断
if (Math.abs(downY - event.getY())> AppUtils.dip2px(getContext(),5)){
isClick = false;
type = -1;
}else {
isClick = true;
}
invalidate();
break;
case MotionEvent.ACTION_UP:
Log.e("日志","是否点击:"+isClick);
//当用户手指重点击屏幕到手指离开屏幕时间小于200毫秒时,被认为时点击时间。写这个方法目的时解决重写onTouchEvent后整个view的点击失效问题。
if (System.currentTimeMillis() - clickTime <= 200 && isClick && type != -1){
clickTime = 0;
performClick();
type = 0;
isClick = false;
downY = 0;
return false;
}
//保证是点击事件才执行,否者用户在手指移动到矩阵内并抬手就快进了。
if (rect.contains((int)event.getX(),(int) (index*singleHeight)) && System.currentTimeMillis() - clickTime <= 200 && isClick && type == -1){
if (clickLryListen != null){
if (index+1 > lys.size()-1){
clickLryListen.sendToProgress(Integer.parseInt(lys.get(lys.size()-1).split("%")[0]));
}else if (index+1 < lys.size()-1){
clickLryListen.sendToProgress(Integer.parseInt(lys.get(index).split("%")[0]));
}
//Log.e("日志","矩阵内容:"+rect.left+","+rect.right+","+rect.top+","+rect.bottom);
//Log.e("日志","点击位点为:"+(int)(event.getY()+Math.abs(defaultMove))+",行数:"+index);
}else {
Log.e("日志","请先调用setClickLrcListen(int time)初始化监听器");
}
}
//这些值使用后一定要恢复默认值
isClick = false;
clickTime = 0;
downY = 0;
//在未设置mssage时,message默认值为0,下面语句目的是清除之前设置的所有延时任务
//三秒后自动改为可同步歌曲进度状态
handler.removeMessages(0);
handler.postDelayed(new Runnable() {
@Override
public void run() {
type = 0;
}
},2000);
break;
}
return true;
}
复制代码
当用户手指接触屏幕瞬间需要记录下点击的坐标及点击发生时间,这两个值分别时downY和clickTime,downY用于计算手指滑动距离,clickTime通过用户手指抬起时间计算用户点击屏幕总时间,用于判断触摸屏幕时时想滑动屏幕还是进行点击操作。isClick值为true时表示用户的动作可能时点击事件,为false时动作肯定不是点击事件。isClick判断依据时MotionEvent.ACTION_MOVE时用户手指移动的距离决定的。当用户手指滑动距离小于5dp内时,可以认为用户可能是想进行点击操作,是不是还需要结合用户点击屏幕的时间及手指离开屏幕的时间的时间差来共同决定。在滑动事件中,我们需要先将type赋值为-1,避免在拖动过程中外部调用updateTimeByIndex方法进行歌词同步操作。在滑动事件中,我们还需要为defaultMove赋值,目的是给ondraw中计算出具体的index,这个index就是被选中歌词的索引。
在MotionEvent.ACTION_UP事件中,我们需要判断用户操作时拖动还是点击,当isClick为true且手指停留在屏幕上的事件小于200毫秒时,可以认为这是一个点击事件。这里我还多加了一个type的判断,type为-1时,表示歌词状态还处于不可同步状态,此时点击view,将会是执行快进,倒退功能。如果type为0,表示view处于可同步状态,此时点击屏幕就和平时的setOnClickListen()后设置的点击响应一样。 clickLryListen.sendToProgress(Integer.parseInt(lys.get(lys.size()-1).split("%")[0]));这句话就是调用外部的方法进行快进倒退用的。执行完这些操作后,后面的值必须回复默认,否则影响下功能的实现。当然也要将view的状态设置为可同步状态。我设定的时间时手指离开屏幕后两秒后自动恢复view为可同步歌词状态。
结束语
这个自定义view其实还是挺简单的,只是说起来有点复杂,上面说的也有点乱,所以看起来也有点烦,不过如果仔细看完后,自己编写出这个view应该也是没问题的。