Android -- [SelfView] 自定义多行歌词滚动显示器

Android -- [SelfView] 自定义多行歌词滚动显示器

流畅、丝滑的滚动歌词控件
 * 1. 背景透明;
 * 2. 外部可控制进度变化;
 * 3. 支持屏幕拖动调节进度(回调给外部);

效果

歌词文件(.lrc)

一. 使用

xml 复制代码
<com.nepalese.harinetest.player.lrc.VirgoLrcView
	android:id="@+id/lrcView"
	android:layout_width="match_parent"
	android:layout_height="match_parent"/>
java 复制代码
private VirgoLrcView lrcView;

lrcView = findViewById(R.id.lrcView);
initLrc();

//==================================
private void initLrc(){
//设置歌词文件 .lrc
//lrcView.setLrc(FileUtils.readTxtResource(getApplicationContext(), R.raw.shaonian, "utf-8"));
	lrcView.setLrc(R.raw.shaonian);
	lrcView.seekTo(0);

	lrcView.setCallback(new VirgoLrcView.LrcCallback() {
		@Override
		public void onUpdateTime(long time) {
        	//拖动歌词返回的时间点
		}

		@Override
		public void onFinish() {
			stopTask();
		}
	});
}

public void onStartPlay(View view) {
	startTask();
}

public void onStopPlay(View view) {
	stopTask();
}

//使用计时器模拟歌曲播放时进度刷新
private long curTime = 0;
private final Runnable timeTisk = new Runnable() {
	@Override
	public void run() {
		curTime += INTERVAL_FLASH;
		lrcView.seekTo(curTime);
	}
};

private void startTask() {
    stopTask();
    handler.post(timeTisk);
}

private void stopTask() {
    handler.removeCallbacks(timeTisk);
}

private final long INTERVAL_FLASH = 400L;
private final Handler handler = new Handler(Looper.myLooper()) {
	@Override
	public void handleMessage(@NonNull Message msg) {
		super.handleMessage(msg);
	}
};

二. 码源

attr.xml

xml 复制代码
<declare-styleable name="VirgoLrcView">
	<attr name="vlTextColorM" format="color|reference" />
	<attr name="vlTextColorS" format="color|reference" />
	<attr name="vlTextSize" format="dimension|reference" />
	<attr name="vlLineSpace" format="dimension|reference" />
</declare-styleable>

VirgoLrcView.java

java 复制代码
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.LinearInterpolator;

import androidx.annotation.Nullable;
import androidx.annotation.RawRes;

import com.nepalese.harinetest.R;
import com.nepalese.harinetest.utils.CommonUtil;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Created by Administrator on 2024/11/26.
 * Usage:更流畅、丝滑的滚动歌词控件
 * 1. 背景透明;
 * 2. 外部可控制进度变化;
 * 3. 支持屏幕拖动调节进度(回调给外部);
 */

public class VirgoLrcView extends View {
    private static final String TAG = "VirgoLrcView";
    private static final float PADD_VALUE = 25f;//时间线两边缩进值
    private static final float TEXT_RATE = 1.25f;//当前行字体放大比例
    private static final long INTERVAL_ANIMATION = 400L;//动画时长
    private static final String DEFAULT_TEXT = "暂无歌词,快去下载吧!";

    private final Context context;
    private Paint paint;//画笔, 仅一个
    private ValueAnimator animator;//动画
    private List<LrcBean> lineList;//歌词行
    private LrcCallback callback;//手动滑动进度刷新回调

    //可设置变量
    private int textColorMain;//选中字体颜色
    private int textColorSec;//其他字体颜色
    private float textSize;//字体大小
    private float lineSpace;//行间距
    private float selectTextSize;//当前选中行字体大小

    private int width, height;//控件宽高
    private int curLine;//当前行数
    private int locateLine;//滑动时居中行数
    private int underRows;//中分下需显示行数
    private float itemHeight;//一行字+行间距
    private float centerY;//居中y
    private float startY;//首行y
    private float oldY;//划屏时起始按压点y
    private float offsetY;//动画已偏移量
    private float offsetY2;//每次手动滑动偏移量
    private long maxTime;//歌词显示最大时间
    private boolean isDown;//按压界面
    private boolean isReverse;//往回滚动?

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

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

    public VirgoLrcView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context;
        init(attrs);
    }

    private void init(AttributeSet attrs) {
        TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.VirgoLrcView);
        textColorMain = ta.getColor(R.styleable.VirgoLrcView_vlTextColorM, Color.CYAN);
        textColorSec = ta.getColor(R.styleable.VirgoLrcView_vlTextColorS, Color.GRAY);
        textSize = ta.getDimension(R.styleable.VirgoLrcView_vlTextSize, 45f);
        lineSpace = ta.getDimension(R.styleable.VirgoLrcView_vlLineSpace, 28f);
        ta.recycle();

        selectTextSize = textSize * TEXT_RATE;
        curLine = 0;
        maxTime = 0;
        isDown = false;
        isReverse = false;
        lineList = new ArrayList<>();

        paint = new Paint();
        paint.setTextSize(textSize);
        paint.setAntiAlias(true);

        calculateItem();
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        if (width == 0 || height == 0) {
            initLayout();
        }
    }

    //控件大小变化时需重置计算
    private void initLayout() {
        width = getWidth();
        height = getHeight();
        centerY = (height - itemHeight) / 2.0f;
        startY = centerY;
        underRows = (int) Math.ceil(height / itemHeight / 3);
        Log.d(TAG, "itemHeight: " + itemHeight + ", underRows: " + underRows);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        //提示无歌词
        if (lineList.isEmpty()) {
            paint.setColor(textColorMain);
            paint.setTextSize(selectTextSize);
            canvas.drawText(DEFAULT_TEXT, getStartX(DEFAULT_TEXT, paint), centerY, paint);
            return;
        }

        if (isDown) {
            paint.setTextSize(textSize);
            paint.setColor(textColorSec);
            //画时间
            if (locateLine >= 0) {
                canvas.drawText(lineList.get(locateLine).getStrTime(), PADD_VALUE, centerY, paint);
            }
            //画选择线
            canvas.drawLine(PADD_VALUE, centerY, width - PADD_VALUE, centerY, paint);

            //手动滑动
            drawTexts(canvas, startY - offsetY2);
        } else {
            //自动滚动
            if (isReverse) {
                drawTexts(canvas, startY + offsetY);
            } else {
                drawTexts(canvas, startY - offsetY);
            }
        }
    }

    private void drawTexts(Canvas canvas, float tempY) {
        for (int i = 0; i < lineList.size(); i++) {
            float y = tempY + i * itemHeight;

            if (y < 0 || y > height) {
                continue;
            }

            if (curLine == i) {
                paint.setTextSize(selectTextSize);
                paint.setColor(textColorMain);
            } else {
                paint.setTextSize(textSize);
                paint.setColor(textColorSec);
            }

            canvas.drawText(lineList.get(i).getLrc(), getStartX(lineList.get(i).getLrc(), paint), y, paint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        super.onTouchEvent(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                isDown = true;
                if (animator != null) {
                    if (animator.isRunning()) {
                        //停止动画
                        animator.end();
                    }
                }
                locateLine = -1;
                oldY = event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                offsetY2 = oldY - event.getY();
                calculateCurLine(oldY - event.getY());//定位时间啊
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                isDown = false;
                postNewLine();
                break;
        }

        return true;
    }

    //计算滑动后当前居中的行
    private void calculateCurLine(float y) {
        int offLine = (int) Math.floor(y / itemHeight);
        if (offLine == 0) {
            return;
        }

        locateLine = curLine + offLine;
        if (locateLine > lineList.size() - 1) {
            //最后一行
            locateLine = lineList.size() - 1;
        } else if (locateLine < 0) {
            //第一行
            locateLine = 0;
        }
    }

    //回调通知,自身不跳转进度
    private void postNewLine() {
        //返回当前行对应的时间线
        if (callback == null) {
            return;
        }
        if (locateLine >= 0) {
            callback.onUpdateTime(lineList.get(locateLine).getTime());
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        releaseBase();
        super.onDetachedFromWindow();
    }

    /**
     * 移除控件,注销资源
     */
    private void releaseBase() {
        cancelAnim();

        if (lineList != null) {
            lineList.clear();
            lineList = null;
        }

        if (callback != null) {
            callback = null;
        }
    }

    private void calculateItem() {
        itemHeight = getTextHeight() + lineSpace;
    }

    //计算使文字水平居中
    private float getStartX(String str, Paint paint) {
        return (width - paint.measureText(str)) / 2.0f;
    }

    //获取文字高度
    private float getTextHeight() {
        Paint.FontMetrics fm = paint.getFontMetrics();
        return fm.descent - fm.ascent;
    }

    //解析歌词
    private void parseLrc(InputStreamReader inputStreamReader) {
        BufferedReader reader = new BufferedReader(inputStreamReader);
        String line;
        try {
            while ((line = reader.readLine()) != null) {
                parseLine(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            inputStreamReader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        maxTime = lineList.get(lineList.size() - 1).getTime() + 1000;//多加一秒
    }

    private long parseTime(String time) {
        // 00:01.10
        String[] min = time.split(":");
        String[] sec = min[1].split("\\.");

        long minInt = Long.parseLong(min[0].replaceAll("\\D+", "")
                .replaceAll("\r", "").replaceAll("\n", "").trim());
        long secInt = Long.parseLong(sec[0].replaceAll("\\D+", "")
                .replaceAll("\r", "").replaceAll("\n", "").trim());
        long milInt = Long.parseLong(sec[1].replaceAll("\\D+", "")
                .replaceAll("\r", "").replaceAll("\n", "").trim());

        return minInt * 60 * 1000 + secInt * 1000 + milInt;// * 10;
    }

    private void parseLine(String line) {
        Matcher matcher = Pattern.compile("\\[\\d.+].+").matcher(line);
        // 如果形如:[xxx]后面啥也没有的,则return空
        if (!matcher.matches()) {
            long time;
            String str;
            String con = line.replace("\\[", "").replace("\\]", "");
            if (con.matches("^\\d.+")) {//time
                time = parseTime(con);
                str = " ";
            } else {
                return;
            }
            lineList.add(new LrcBean(time, str, con));
            return;
        }

        //[00:23.24]让自己变得快乐
        line = line.replaceAll("\\[", "");
        String[] result = line.split("]");
        lineList.add(new LrcBean(parseTime(result[0]), result[1], result[0]));
    }

    private void reset() {
        lineList.clear();
        curLine = 0;
        maxTime = 0;
        isReverse = false;
        cancelAnim();
    }

    ///动画/

    /**
     * 更新动画
     *
     * @param lineNum 需跳转行数
     */
    private void updateAnim(int lineNum) {
        if (lineNum == 0) {
            return;
        } else if (lineNum == 1) {
            //自然变化
            if (curLine >= lineList.size() - underRows) {
                //停止动画 仅变更颜色
                cancelAnim();
                invalidate();
                return;
            }
        }
        isReverse = lineNum < 0;
        cancelAnim();
        setAnimator(Math.abs(lineNum));
        doAnimation();
    }

    /**
     * 注销已有动画
     */
    protected void cancelAnim() {
        if (animator != null) {
            animator.removeAllListeners();
            animator.end();
            animator = null;
        }
    }


    /**
     * 动态创建动画
     *
     * @param lineNum 需跳转行数
     */
    private void setAnimator(int lineNum) {
        animator = ValueAnimator.ofFloat(0, itemHeight * lineNum);//一行
        animator.setDuration(INTERVAL_ANIMATION);
        animator.setInterpolator(new LinearInterpolator());//插值器设为线性
    }

    /**
     * 监听动画
     */
    private void doAnimation() {
        if (animator == null) {
            return;
        }

        animator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
                offsetY = 0;
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                if (isReverse) {
                    startY += offsetY;
                } else {
                    startY -= offsetY;
                }
                offsetY = 0;
                invalidate();
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });

        animator.addUpdateListener(animation -> {
            float av = (float) animation.getAnimatedValue();
            if (av == 0) {
                return;
            }
            offsetY = av;
            invalidate();
        });

        animator.start();
    }

    public interface LrcCallback {
        void onUpdateTime(long time);

        void onFinish();
    }

    

    /**
     * 滑动监听
     *
     * @param callback LrcCallback
     */
    public void setCallback(LrcCallback callback) {
        this.callback = callback;
    }

    public void setTextColorMain(int textColorMain) {
        this.textColorMain = textColorMain;
    }

    public void setTextColorSec(int textColorSec) {
        this.textColorSec = textColorSec;
    }

    public void setTextSize(float textSize) {
        this.textSize = textSize;
        this.selectTextSize = textSize * TEXT_RATE;
        paint.setTextSize(textSize);
        calculateItem();
    }

    public void setLineSpace(float lineSpace) {
        this.lineSpace = lineSpace;
        calculateItem();
    }

    /**
     * 设置歌词
     *
     * @param lrc 解析后的string
     */
    public void setLrc(String lrc) {
        if (TextUtils.isEmpty(lrc)) {
            return;
        }
        reset();
        parseLrc(new InputStreamReader(new ByteArrayInputStream(lrc.getBytes())));
    }

    /**
     * 设置歌词
     *
     * @param resId 资源文件id
     */
    public void setLrc(@RawRes int resId) {
        reset();
        parseLrc(new InputStreamReader(context.getResources().openRawResource(resId), StandardCharsets.UTF_8));
    }

    /**
     * 设置歌词
     *
     * @param path lrc文件路径
     */
    public void setLrcFile(String path) {
        File file = new File(path);
        if (file.exists()) {
            reset();
            String format;
            if (CommonUtil.isUtf8(file)) {
                format = "UTF-8";
            } else {
                format = "GBK";
            }

            FileInputStream inputStream = null;
            try {
                inputStream = new FileInputStream(file);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }

            InputStreamReader inputStreamReader = null;//'utf-8' 'GBK'
            try {
                inputStreamReader = new InputStreamReader(inputStream, format);
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }

            parseLrc(inputStreamReader);
        }
    }

    /**
     * 调整播放位置
     *
     * @param time ms
     */
    public void seekTo(long time) {
        if (isDown) {
            //拖动歌词时暂不处理
            return;
        }

        if (time == 0) {
            //刷新
            invalidate();
            return;
        } else if (time > maxTime) {
            //超最大时间:通知结束
            if (callback != null) {
                callback.onFinish();
            }
            return;
        }

        for (int i = 0; i < lineList.size(); i++) {
            if (i < lineList.size() - 1) {
                if (time >= lineList.get(i).getTime() && time < lineList.get(i + 1).getTime()) {
                    int temp = i - curLine;
                    curLine = i;

                    updateAnim(temp);
                    break;
                }
            } else {//last line
                int temp = i - curLine;
                curLine = i;

                updateAnim(temp);
                break;
            }
        }
    }
}
相关推荐
m0_748235954 小时前
CentOS 7使用RPM安装MySQL
android·mysql·centos
ac-er88887 小时前
Yii框架中的队列:如何实现异步操作
android·开发语言·php
流氓也是种气质 _Cookie9 小时前
uniapp 在线更新应用
android·uniapp
zhangphil11 小时前
Android ValueAnimator ImageView animate() rotation,Kotlin
android·kotlin
徊忆羽菲12 小时前
CentOS7使用源码安装PHP8教程整理
android
编程、小哥哥13 小时前
python操作mysql
android·python
Couvrir洪荒猛兽13 小时前
Android实训十 数据存储和访问
android
五味香16 小时前
Java学习,List 元素替换
android·java·开发语言·python·学习·golang·kotlin
十二测试录16 小时前
【自动化测试】—— Appium使用保姆教程
android·经验分享·测试工具·程序人生·adb·appium·自动化
Couvrir洪荒猛兽18 小时前
Android实训九 数据存储和访问
android