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)
    }
}
相关推荐
Liben3 小时前
Weex开发踩坑记录
android·前端·ios
jerrywus3 小时前
AI 写的代码能跑,为什么接手的人想跑?
前端·agent·claude
山雀~3 小时前
Web 多语言(i18n)技术方案详解
前端
biu_biubiu4 小时前
我开源了 Codebook
前端·vue.js
下山4 小时前
怎么把带自研插件的 DeepSeek Harness 部署到云服务器
前端
彭亚川Allen4 小时前
985 在职研究生到底怎么样?是不是水硕?是不是.....?
前端·后端·程序员
YufJi4 小时前
两棵树,两个线程:一个小程序引擎的架构拆解
前端
super_Omni4 小时前
Chrome 插件入驻实战:从 Manifest V3 到过审上架的完整路径
大数据·前端·chrome
腾讯数据架构师4 小时前
任务跑完怎么直接出图表?CubeStudio 任务可视化 ECharts 数据格式实操(CSV/JSON + metric.json,11 种图表一键渲染)
前端·json·echarts·数据可视化
小小小米粒5 小时前
不是在构造方法「之前」修饰 Bean、注入;是构造方法跑完之后,才做属性注入,再跑 @PostConstruct。
前端