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)
    }
}
相关推荐
Hyyy20 分钟前
理解LLM的基本工作原理:预训练、微调、推理的区别
前端
Gatlin1 小时前
前端逆向与反逆向:一场猫鼠游戏的底层逻辑与实战
前端
Pedantic1 小时前
本地通知(Local Notifications)学习笔记
前端
森蓝情丶2 小时前
我给 AI 搭了个法庭:一个前端仔的 LangGraph 实战全记录
前端·后端
爱勇宝2 小时前
干了近 8 年,一夜之间被裁:AI 时代,程序员最该害怕的不是 AI
前端·后端·程序员
Pedantic2 小时前
Combine 框架学习笔记
前端
runnerdancer2 小时前
Agent如何加载执行Skill的脚本
前端·agent
yingyima2 小时前
VS Code 正则替换技巧:从凌晨3点的服务器报警开始
前端
默_笙2 小时前
🛬 我让 AI 帮我写了一个打飞机游戏,结果 Canvas 把我整不会了
前端·javascript
梯度不陡2 小时前
AI 到底能不能从零写软件?ProgramBench 和 RepoZero 给出了两种答案
前端·javascript·面试