连续滚动的TextView

106 阅读4分钟

跑马灯TextView

TextView提供的marquee其实已经能满足大多数人的需求,但是有一种特殊情况,就是在跑马灯滚动的时候,你再setText,会发现滚动动画会重新开始,搜了搜网上好像并没有对这种情况做处理,所以以下提供两种解决方案。

方案一

/**
 * 一直滚动的textview
 * */
class ContinuousMarqueeTextView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {

    private var scrollX = 0f // 当前滚动的偏移量
    private var textWidth = 0f // 文本宽度
    private var scroller: Scroller? = null

    private var isScrolling = false
    var spaceWidth = 10f // 文本之间的空白宽度(单位:像素)

    init {
        isFocusable = true
        ellipsize = null // 禁用系统的跑马灯
        scroller = Scroller(context, LinearInterpolator())
        startScroll()
    }

    private var viewWidth = 0f // 控件宽度

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewWidth = w.toFloat()
    }

    private val bounds = Rect()
    private var isBoundsDirty = true
    private fun TextPaint.getCachedBounds(text: String): Rect {
        if (isBoundsDirty) {
            this.getTextBounds(text, 0, text.length, bounds)
            isBoundsDirty = false
        }
        return bounds
    }

    override fun onDraw(canvas: Canvas) {
        val paint: TextPaint = paint
        paint.isAntiAlias = true

        // 获取文本内容
        val text = text.toString()
        if (text.isEmpty()) return

        textWidth = paint.getCachedBounds(text).width().toFloat()
        if (textWidth <= viewWidth) {
            isScrolling = false // 停止滚动
            val xPos = (viewWidth - textWidth) / 2 // 计算文本居中位置
            canvas.drawText(text, xPos, height / 2f + textSize / 2f, paint)
            stopScroll()
            return
        } else {
            startScroll()
        }

        // 计算滚动后的偏移量
        scrollX += 2f // 每次滚动的距离
        if (scrollX >= textWidth + spaceWidth) {
            scrollX = 0f // 滚动完成后重置
        }

        canvas.drawText(text, -scrollX, height / 2f + textSize / 2f, paint) // 当前文本
        canvas.drawText(
            text,
            -scrollX + textWidth + spaceWidth,
            height / 2f + textSize / 2f,
            paint
        ) // 后续文本
        if (isScrolling) {
            postInvalidateOnAnimation()
        }
    }


    fun updateText(newText: String) {
        if (newText != text) {
            text = newText
            isBoundsDirty = true
        }
    }

    /**
     * 开始滚动
     */
    private fun startScroll() {
        if (!isScrolling) {
            isScrolling = true
            invalidate()
        }
    }

    /**
     * 停止滚动
     */
    private fun stopScroll() {
        isScrolling = false
    }

}

这种方案会受到帧率影响,但是连续效果会好点

方案二

/**
 * 持续滚动的textview
 * */
class ContinueTextView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
    private var scroller: Scroller? = null
    private var viewWidth = 0f // 控件宽度
    private val bounds = Rect()
    private var isBoundsDirty = true
    private var isScrolling = false
    private fun TextPaint.getCachedBounds(text: String): Rect {
        if (isBoundsDirty) {
            this.getTextBounds(text, 0, text.length, bounds)
            isBoundsDirty = false
        }
        return bounds
    }

    init {
        isFocusable = true
        ellipsize = null // 禁用系统的跑马灯
        paint.isAntiAlias = true
        scroller = Scroller(context, LinearInterpolator())
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewWidth = w.toFloat()
    }

    /**
     * 轮播滚动
     * */
    private fun startScroll() {
        if (viewWidth == 0f) {
            // 控件还没初始化完成,不滚动
            return
        }
        if (text.isEmpty()) {
            // 没有文本内容,不滚动
            return
        }
        if (checkCanScrolled(text.toString())) {
            // 文本宽度小于控件宽度,不滚动
            return
        }
        val textWidth = paint.getCachedBounds(text.toString()).width().toFloat()
        if (!isScrolling) {
            // 动态调整滚动速度
            val pixelsPerSecond = when {
                textWidth < viewWidth * 2 -> 250 // 短文本稍快
                textWidth > viewWidth * 5 -> 180 // 长文本稍慢
                else -> 200 // 默认速度
            }
            val duration = ((textWidth + viewWidth) / pixelsPerSecond * 1000).toLong()
            scroller?.startScroll(
                -(viewWidth).toInt(),
                0,
                textWidth.toInt() + (viewWidth).toInt(),
                0,
                duration.toInt()
            )
            scrollTo(textWidth.toInt(), 0)
            isScrolling = true
            postInvalidateOnAnimation()
        }
    }

    /**
     * 更新文字
     * */
    fun updateText(newText: String) {
        if (newText != text) {
            text = newText
            isBoundsDirty = true
            checkCanScrolled(newText)
        }
    }

    /**
     * 停止滚动
     */
    private fun stopScroll() {
        scroller?.abortAnimation()
        isScrolling = false
    }

    override fun computeScroll() {
        if (scroller?.computeScrollOffset() == true) {
            scrollTo(scroller?.currX ?: 0, 0)
            postInvalidateOnAnimation()
        } else {
            stopScroll()
            startScroll()
        }
    }

    private fun checkCanScrolled(newText: String): Boolean {
        val textWidth = paint.getCachedBounds(newText).width().toFloat()
        if (textWidth <= viewWidth) {
            //如果不可以滚动,停止滚动,将文本居中显示
            stopScroll()
            val xPos = -(viewWidth - textWidth) / 2
            scrollTo(xPos.toInt(), 0)
            invalidate()
            return true
        }
        return false
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        scroller?.abortAnimation()
        scroller = null
    }
}

方案三

/**
 * 使用 ValueAnimator 实现的跑马灯效果 TextView
 */
class ContinuousValueAnimatorMarqueeTextView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {

    private var scrollOffset = 0f // 当前滚动偏移量
    private var textWidth = 0f // 文本宽度
    private var viewWidth = 0f // 控件宽度
    private val bounds = Rect()
    private var animator: ValueAnimator? = null
    private var canScroll = true // 是否可以滚动
    var spaceWidth = 50f // 文本之间的间隔宽度(单位:像素)

    init {
        isFocusable = true
        ellipsize = null // 禁用系统跑马灯效果
        startScroll()
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        viewWidth = w.toFloat()
    }

    private fun TextPaint.getTextWidth(text: String): Float {
        getTextBounds(text, 0, text.length, bounds)
        return bounds.width().toFloat()
    }

    override fun onDraw(canvas: Canvas) {
        val paint: TextPaint = paint
        paint.isAntiAlias = true

        val text = text.toString()
        if (text.isEmpty()) return

        // 计算文本宽度
        textWidth = paint.getTextWidth(text)
        if (textWidth <= viewWidth) {
            // 如果文本宽度小于控件宽度,不滚动,居中显示
            val xPos = (viewWidth - textWidth) / 2
            canvas.drawText(text, xPos, height / 2f + textSize / 2f, paint)
            return
        }
        // 计算滚动后的偏移量
        scrollOffset += 2f
        // 每次滚动的距离
        if (scrollOffset >= textWidth + spaceWidth) {
            scrollOffset = 0f // 滚动完成后重置
        }

        // 滚动效果
        canvas.drawText(
            text,
            if (ResourceUtils.isLayoutRtl()) scrollOffset else -scrollOffset,
            height / 2f + textSize / 2f,
            paint
        )
        canvas.drawText(
            text,
            if (ResourceUtils.isLayoutRtl()) -(-scrollOffset + textWidth + spaceWidth) else -scrollOffset + textWidth + spaceWidth,
            height / 2f + textSize / 2f,
            paint
        )
    }

    /**
     * 开始滚动
     */
    fun startScroll() {
        if (text.isEmpty()) return // 没有文本内容,不需要滚动
        textWidth = paint.getTextWidth(text.toString()) // 计算文本宽度
        if (textWidth <= viewWidth) return // 如果文本太短,不需要滚动
        if (animator?.isRunning == true) return // 动画已经运行中
        if (!canScroll) return//不需要滚动
        val totalScrollDistance = textWidth + spaceWidth // 总共需要滚动的距离
        val pixelsPerSecond = when {
            textWidth < viewWidth * 2 -> 250 // 短文本稍快
            textWidth > viewWidth * 5 -> 180 // 长文本稍慢
            else -> 200 // 默认速度
        }
        val scrollDuration = ((textWidth + viewWidth) / pixelsPerSecond * 1000).toLong()
        animator = ValueAnimator.ofFloat(0f, totalScrollDistance).apply {
            duration = scrollDuration
            interpolator = LinearInterpolator()
            addUpdateListener {
                postInvalidateOnAnimation()
            }
            doOnEnd {
                if (canScroll && textWidth > viewWidth) {
                    startScroll() // 重启动画
                }
            }
            start()
        }
    }

    /**
     * 禁止自动滚动
     * */
    fun stopAutoScroll() {
        canScroll = false
        stopScroll()
    }

    /**
     * 开始自动滚动
     */
    fun startAutoScroll() {
        canScroll = true
        startScroll()
    }


    /**
     * 停止滚动
     */
    private fun stopScroll() {
        animator?.cancel()
        animator = null
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        stopScroll()
    }

    /**
     * 更新文本内容
     */
    fun updateText(newText: String) {
        if (newText != text) {
            text = newText
            stopScroll()
            startScroll() // 重启动画
        }
    }
}

这种不受帧率影响,但是连续效果不如第一种好,可以根据实际情况,适当使用上述两种方案,或者有其他方案可以在底下@我,多谢