自定义View:秀一个音乐app的歌词变色TextView

前言

从2014年开始从事安卓的工作,就一直关注郭霖大神的博客,慢慢的到微信公众号,再到第三行代码。不得不说书中介绍的非常详细,帮助我们把平时接触到的碎片知识整合到一起,书中摒弃了市面上大部分Android书籍大篇幅的讲解系统API的内容,更加贴近实战。个人感觉最适合中级开发者,尽管我也干了6个年头,仍然受益匪浅,是本难得的好书。

我个人比较喜欢做一些有意思的东西,所以这次把最近做的一个小功能跟大家分享一下:仿音乐App歌词播放变色的TextView。
自定义View:秀一个音乐app的歌词变色TextView_第1张图片

正文

首先先看一下效果图:

我们为这个自定义控件起名为:SongLyricTextView。要想实现这个效果唯一要解决的问题是如何计算播放文字变色的进度,我就直接抛砖引玉,说一下自己的实现方案。

  1. 指定播放开始索引和结束索引的坐标,例如从0到10;
  2. 截取子字符串从0到10,计算子字符串的宽度playWidth;
  3. 开启差值器,从0到playWidth,设置duration;
  4. 通过差值器不停返回的宽度,计算canvas中需要绘制变色区域;
  5. 在原本黑色的文字上,绘制一遍变色的字;

接下来就按照这个步骤实现demo中的效果,首先我们定义基本的属性:

	/**
     * 文字未播放的颜色
     * */
    private val normalColor = Color.parseColor("#333333")

    /**
     * 文字播放的颜色
     * */
    private val playColor = Color.parseColor("#fec403")

    /**
     * 要变色的宽度
     * */
    private var consumeWidth: Float = 0f

    /**
     * 是否正在播放中
     * */
    private var isPlaying = false

    private val mPaint = paint

    /**
     * 要变色的区域
     * */
    private val path = Path()
    
	/**
     * 负责变色差值器
     * */
    private val animator by lazy {
        val animator = ValueAnimator.ofFloat(0f, 1f)
        animator.interpolator = LinearInterpolator()
        animator.addUpdateListener {
        	// 开始重绘
            consumeWidth = it.animatedValue as Float
            invalidate()
        }
        animator
    }

接下来,把刚才提到的前三步整合为一个开始播放的方法:

  1. 指定播放开始索引和结束索引的坐标,例如从0到10;
  2. 截取子字符串从0到10,计算子字符串的宽度playWidth;
  3. 开启差值器,从0到playWidth,设置duration;
/**
     * 开始播放
     *
     * @param startIndex 开始的文字的索引
     * @param endIndex 结束的文字的索引
     * @param duration 播放时长
     * */
    fun startPlayLine(startIndex: Int, endIndex: Int, duration: Long) {
        isPlaying = true
        if (startIndex == -1) return
        // 计算从开始位置到开始的文字宽度,可以理解为已经播放完毕的文字宽度
        val startWidth = mPaint.measureText(this.text.substring(0, startIndex))
        // 计算即将播放截止的文字的宽度,其实也可以直接this.text.substring(0, endIndex),问题不大
        val endWidth = startWidth + mPaint.measureText(this.text.substring(startIndex, endIndex))
        // 启动差值器
        animator.setFloatValues(startWidth, endWidth)
        animator.duration = if (duration > 0) duration else 1000
        animator.start()
    }

    /**
     * 停止播放
     * */
    fun stopPlay() {
        isPlaying = false
        animator.cancel()
        invalidate()
    }

有开始播放的方法,那就肯定要有停止播放,代码也非常的简单。因为之前工作的需求是从第一句开始播放,如果你想要播放中间某一段文字,可以对demo中的代码自己做一些修改。

最后是计算canvas绘制的区域:

 override fun onDraw(canvas: Canvas) {
 		// 调用一遍super,把黑色的字写一遍
        mPaint.color = normalColor
        super.onDraw(canvas)

        if (layout == null) {
            invalidate()
            return
        }
        // 是否是播放状态
        if (isPlaying) {
            path.reset()
            // 因为需求是整行播放,所以可以通过行数来遍历
            val lineCount = layout.lineCount
            val content = text.toString()
            for (i in 0 until lineCount) {
                // 计算一行文字的宽度
                val lineWidth = mPaint.measureText(
                    content.substring(layout.getLineStart(i), layout.getLineEnd(i))
                )
				// 如果已经播放过了
                if (lineWidth <= consumeWidth) {
                    // 如果是之前已经变色区域,直接添加到path中
                    // 减去这一行的宽度
                    consumeWidth -= lineWidth
                    path.addRect(
                        layout.getLineLeft(i),
                        layout.getLineTop(i).toFloat(),
                        layout.getLineRight(i),
                        layout.getLineBottom(i).toFloat(),
                        Path.Direction.CCW
                    )
                } else {
                    // 如果该行正好是要变色的行,直接改变颜色
                    // 把需要的consumeWidth放入path中
                    path.addRect(
                        layout.getLineLeft(i),
                        layout.getLineTop(i).toFloat(),
                        layout.getLineLeft(i) + consumeWidth,
                        layout.getLineBottom(i).toFloat(),
                        Path.Direction.CCW
                    )
                    break
                }
            }
            // 设置需要绘制的区域,绘制一遍变色的字
            canvas.clipPath(path)
            mPaint.color = playColor
            layout.draw(canvas)
        }
    }

最后看一下demo中具体使用SongLyricTextView的用法:

// 开始播放
start.setOnClickListener {
     val lyricList = lyric.split("\n")
     var startIndex = 0
     var delayTime = 0L
     lyricList.forEach {
         val startIndexTemp = startIndex
         val duration = (500 + Math.random() * 500).toLong()
         text.postDelayed(
             {
                 text.startPlayLine(
                     startIndexTemp,
                     min(lyric.length, startIndexTemp + it.length + 1), // 因为有个换行符,所以 + 1
                     duration
                 )
             },
             delayTime
         )
         delayTime += duration + 50
         startIndex += it.length + 1
     }
}
// 停止播放
end.setOnClickListener {
    text.stopPlay()
}

到此demo的效果已经完成。

神奇的Layout

我在之前自己的博客中已经安利了一波Layout的流弊之处,今天的demo特别适合当典型案例,所以就再唠叨一次,已经看过上一篇:自定义View:可伸展折叠的ExpandTextView的朋友可以跳过这一部分。

Layout在android.text包下,是专门辅助TextView绘制文字的类,下面介绍一下Layout的常用方法:

// 返回某一行开始的文字的索引,如果line等于lineCount,会返回字符串的长度
public abstract int getLineStart(int line);
// 返回某一行最后一个字符的索引
public final int getLineEnd(int line) 
// 返回某一个距离顶部的top
public abstract int getLineTop(int line);
// 返回某一行左边的间距
public float getLineLeft(int line)
// 返回某一行右边的间距
public float getLineRight(int line)
// 返回某一行下面的间距
public final int getLineBottom(int line)
// 获得某一行的宽度
public float getLineWidth(int line)
// 返回某个位置所在的行数
public int getLineForOffset(int offset)

Layout还有很多其他的方法,大家有时间可以多研究研究。

结尾

今天分享的案例要是大家还有新的方案,可以一起讨论学习。第三行代码已看完大部分,尤其是kotlin的一些技巧确实收获不少,还在犹豫的朋友赶紧抓紧时间了。

点击查看源码:github源码地址

你可能感兴趣的:(自定义View系列)