Android跑马灯控件

因为AI,所以deepseek

一个跑马灯控件,从右到左滚动

在项目中,很容易就用AI生成了高质量的自定义view

细节

private const val FRAME_INTERVAL_MS = 50L 也就是说跑马灯不是每帧刷新,而是大约 50ms 刷新一次,相当于 20fps。 所以每次文字移动的距离大概是:

每次位移 = rtv_speed * 50 / 1000

几个值的效果大概是:

rtv_speed 每 50ms 位移 视觉效果
18 0.9px 很平滑,但偏慢
24 1.2px 比较自然
30 1.5px 还能接受
40 2px 开始容易看出跳动
60 3px 一卡一卡会明显

弹出 Dialog 的时候,主线程还要做 Dialog inflate、绑定数据、图片加载请求、AutoSize 适配等操作,可能会丢几帧。 当 rtv_speed=60 时,一旦跳过 100ms~150ms,文字会瞬间跳:

60px/s * 0.1s = 6px 60px/s * 0.15s = 9px

所以"卡顿感"会特别明显。 而 rtv_speed=24 时,同样丢 150ms,也只是跳 3.6px,肉眼不那么敏感。

代码

kotlin 复制代码
package com.example.card.widget

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.text.TextPaint
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Choreographer
import android.view.View
import android.view.animation.AnimationUtils
import com.example.card.R
import java.util.WeakHashMap
import kotlin.math.ceil
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.withClip
import timber.log.Timber

/**
 * 单行标题控件:文本超过可用宽度时横向循环滚动。
 * ```
 *  XML 可通过 `app:rtv_textGap` 配置首尾间距,通过 `app:rtv_speed` 配置滚动速度(px/s)。
 * `app:rtv_fadeEdgeWidth` 和 `app:rtv_fadeEdgeColor` 可为滚动文本增加左右渐隐蒙层。
 * ```
 * 注意点:商品详情的FullDialog弹出后,虽然遮蔽里全屏,但是并不会让跑马灯停止。这是FullDialog的问题,
 *       因为Dialog本身并不是设计用于全屏内容展示的;
 */
class RollingTitleView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
    private val fadePaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var textValue: CharSequence = ""
    private var textString = ""
    private var textWidth = 0f
    private var textBaseLine = 0f
    private var loopDistance = 0f
    private var shouldRoll = false
    private var startTimeMs = 0L
    private var leftFadeShader: LinearGradient? = null
    private var rightFadeShader: LinearGradient? = null
    private val tickerListener = RollingTitleTicker.Listener { onTick() }

    var speedPxPerSecond = 24f
        set(value) {
            field = value.coerceAtLeast(1f)
            invalidate()
        }

    var textGapPx = dpToPx(24f)
        set(value) {
            field = value
            updateRollingState()
            invalidate()
        }

    var fadeEdgeWidthPx = 0f
        set(value) {
            field = value.coerceAtLeast(0f)
            updateFadeShaders()
            invalidate()
        }

    var fadeEdgeColor = Color.WHITE
        set(value) {
            field = value
            updateFadeShaders()
            invalidate()
        }

    init {
        textPaint.color = Color.BLACK
        textPaint.textSize = spToPx(15f)

        textString = textValue.toString()

        if (attrs != null) {
            context.withStyledAttributes(attrs, R.styleable.RollingTitleView) {
                textValue = getText(R.styleable.RollingTitleView_android_text) ?: ""
                textString = textValue.toString()
                textPaint.color =
                    getColor(R.styleable.RollingTitleView_android_textColor, Color.BLACK)
                textPaint.textSize =
                    getDimension(R.styleable.RollingTitleView_android_textSize, spToPx(15f))
                textGapPx = getDimension(R.styleable.RollingTitleView_rtv_textGap, textGapPx)
                speedPxPerSecond = getFloat(R.styleable.RollingTitleView_rtv_speed, speedPxPerSecond)
                fadeEdgeWidthPx = getDimension(R.styleable.RollingTitleView_rtv_fadeEdgeWidth, fadeEdgeWidthPx)
                fadeEdgeColor = getColor(R.styleable.RollingTitleView_rtv_fadeEdgeColor, fadeEdgeColor)
            }
        }
    }

    fun setText(text: CharSequence?) {
        val newText = text ?: ""
        if (textValue == newText) return
        textValue = newText
        textString = newText.toString()
        textWidth = textPaint.measureText(textString)
        startTimeMs = 0L
        requestLayout()
        updateRollingState()
        invalidate()
    }

    fun getText(): CharSequence = textValue

    fun setTextColor(color: Int) {
        if (textPaint.color == color) return
        textPaint.color = color
        invalidate()
    }

    fun setTextSizeSp(sizeSp: Float) {
        val newSize = spToPx(sizeSp)
        if (textPaint.textSize == newSize) return
        textPaint.textSize = newSize
        textWidth = textPaint.measureText(textString)
        requestLayout()
        updateRollingState()
        invalidate()
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        updateRollingState()
    }

    override fun onDetachedFromWindow() {
        RollingTitleTicker.remove(tickerListener)
        super.onDetachedFromWindow()
    }

    override fun onVisibilityChanged(changedView: View, visibility: Int) {
        super.onVisibilityChanged(changedView, visibility)
        updateRollingState()
    }

    override fun onWindowVisibilityChanged(visibility: Int) {
        super.onWindowVisibilityChanged(visibility)
        updateRollingState()
    }

    override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
        super.onWindowFocusChanged(hasWindowFocus)
        updateRollingState()
    }

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

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        textWidth = textPaint.measureText(textString)

        val fontMetrics = textPaint.fontMetrics
        val desiredWidth = paddingLeft + paddingRight + ceil(textWidth).toInt()
        val desiredHeight = paddingTop + paddingBottom + ceil(fontMetrics.descent - fontMetrics.ascent).toInt()
        val measuredWidth = resolveSize(desiredWidth, widthMeasureSpec)
        val measuredHeight = resolveSize(desiredHeight, heightMeasureSpec)

        setMeasuredDimension(measuredWidth, measuredHeight)

        textBaseLine = paddingTop + (measuredHeight - paddingTop - paddingBottom) / 2f -
                (fontMetrics.ascent + fontMetrics.descent) / 2f

        updateRollingState()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if (textString.isEmpty()) return

        val availableWidth = width - paddingLeft - paddingRight
        if (availableWidth <= 0) return

        if (!shouldRoll) {
            canvas.drawText(textString, paddingLeft.toFloat(), textBaseLine, textPaint)
            return
        }

        val offset = calculateScrollOffset()
        canvas.withClip(paddingLeft, paddingTop, width - paddingRight, height - paddingBottom) {
            drawText(textString, paddingLeft - offset, textBaseLine, textPaint)
            drawText(textString, paddingLeft - offset + loopDistance, textBaseLine, textPaint)
        }
        drawFadeEdges(canvas)
    }

    private fun onTick() {
        if (shouldRoll && isShown && windowVisibility == VISIBLE) {
            invalidate()
        } else {
            updateRollingState()
        }
    }

    private fun updateRollingState() {
        val availableWidth = width - paddingLeft - paddingRight
        val canRoll = isAttachedToWindow &&
                isShown &&
                windowVisibility == VISIBLE &&
                hasWindowFocus() &&
                availableWidth > 0 &&
                textValue.isNotEmpty() &&
                textWidth > availableWidth

//        if (shouldRoll != canRoll) {
//            Timber.d("RollingTitleView rolling=%s text=%s width=%d textWidth=%.1f hasFocus=%s", canRoll, textString, availableWidth, textWidth, hasWindowFocus())
//        }
        shouldRoll = canRoll
        loopDistance = textWidth + textGapPx

        if (canRoll) {
            if (startTimeMs == 0L) startTimeMs = AnimationUtils.currentAnimationTimeMillis()
            RollingTitleTicker.add(tickerListener)
        } else {
            startTimeMs = 0L
            RollingTitleTicker.remove(tickerListener)
        }
    }

    private fun calculateScrollOffset(): Float {
        if (!shouldRoll || loopDistance <= 0f) return 0f

        val now = AnimationUtils.currentAnimationTimeMillis()
        if (startTimeMs == 0L) startTimeMs = now

        val elapsedSeconds = (now - startTimeMs) / 1000f
        return elapsedSeconds * speedPxPerSecond % loopDistance
    }

    private fun drawFadeEdges(canvas: Canvas) {
        if (fadeEdgeWidthPx <= 0f) return

        val leftShader = leftFadeShader ?: return
        val rightShader = rightFadeShader ?: return
        val left = paddingLeft.toFloat()
        val right = (width - paddingRight).toFloat()
        val top = paddingTop.toFloat()
        val bottom = (height - paddingBottom).toFloat()
        val edgeWidth = fadeEdgeWidthPx.coerceAtMost((right - left) / 2f)

        fadePaint.shader = leftShader
        canvas.drawRect(left, top, left + edgeWidth, bottom, fadePaint)
        fadePaint.shader = rightShader
        canvas.drawRect(right - edgeWidth, top, right, bottom, fadePaint)
        fadePaint.shader = null
    }

    private fun updateFadeShaders() {
        val left = paddingLeft.toFloat()
        val right = (width - paddingRight).toFloat()
        val edgeWidth = fadeEdgeWidthPx.coerceAtMost((right - left) / 2f)
        if (edgeWidth <= 0f) {
            leftFadeShader = null
            rightFadeShader = null
            return
        }

        val transparentColor = fadeEdgeColor and 0x00FFFFFF
        leftFadeShader = LinearGradient(
            left,
            0f,
            left + edgeWidth,
            0f,
            fadeEdgeColor,
            transparentColor,
            Shader.TileMode.CLAMP
        )
        rightFadeShader = LinearGradient(
            right - edgeWidth,
            0f,
            right,
            0f,
            transparentColor,
            fadeEdgeColor,
            Shader.TileMode.CLAMP
        )
    }

    private fun spToPx(sp: Float): Float {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics)
    }

    private fun dpToPx(dp: Float): Float {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
    }
}

private object RollingTitleTicker : Choreographer.FrameCallback {

    // 商品列表里可能同时存在多个滚动标题,共用一个 Choreographer 回调可减少定时器数量。
    fun interface Listener {
        fun onTick()
    }

    private const val FRAME_INTERVAL_MS = 50L

    private val listeners = WeakHashMap<Listener, Boolean>()
    private var running = false
    private var lastFrameTimeMs = 0L

    fun add(listener: Listener) {
        listeners[listener] = true
        if (!running) {
            running = true
            lastFrameTimeMs = 0L
            Choreographer.getInstance().postFrameCallback(this)
        }
    }

    fun remove(listener: Listener) {
        listeners.remove(listener)
        if (listeners.isEmpty()) {
            running = false
            Choreographer.getInstance().removeFrameCallback(this)
        }
    }

    override fun doFrame(frameTimeNanos: Long) {
        if (!running || listeners.isEmpty()) {
            running = false
            return
        }

        val frameTimeMs = frameTimeNanos / 1_000_000L
        if (lastFrameTimeMs == 0L || frameTimeMs - lastFrameTimeMs >= FRAME_INTERVAL_MS) {
            lastFrameTimeMs = frameTimeMs
            listeners.keys.toList().forEach { it.onTick() }
        }

        Choreographer.getInstance().postFrameCallback(this)
    }
}
相关推荐
光影少年1 小时前
react全局状态、局部状态、服务端状态如何选型
前端·react.js·掘金·金石计划
甄心爱学习1 小时前
【项目实训(个人10)】
开发语言·前端·javascript
7yue1 小时前
我用 AI 把 Learn Claude Code 改写成了 TypeScript + 代数效应版本
前端
云宝大王1 小时前
JavaScript 异步编程:从回调到探索 Promise的秘密
前端·javascript
daols881 小时前
vxe-table 进阶:同时使用 formatter 与 cell-render 实现格式化与样式定制
前端·javascript·vue.js·vxe-table
用户059540174461 小时前
用LangChain+FastAPI构建私有知识库踩坑实录:这3个问题让我排查了整整8小时
前端·css
Momo__1 小时前
CSS View Transitions 新语法:sibling-index() + ident(),千级元素命名难题的终局方案
前端·css
前端张三2 小时前
ant design vue table 使用虚拟滚动
前端·javascript·vue.js
木子雨廷2 小时前
Flutter 内存管理实战:从 GC 原理到 DevTools 泄漏排查
前端·flutter