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)
    }
}
相关推荐
benbenAItalk6 小时前
数字人口播视频的批量生产实践:素材规范、任务编排与质量验收
java·前端·音视频
2601_962885726 小时前
如何用 Python 扫描 A 股跳空缺口并统计缺口回补概率?
java·前端·python
西瓜太郎12347 小时前
Claude Code、Codex CLI、Gemini CLI 能否共用一枚 Key?先看协议选择矩阵
前端·api 网关·claude code·gemini cli·codex cli
陈随易9 小时前
在Finch用了62亿词元,我认为这是新一代Agent工具之神
前端·人工智能·后端
水域安全老周9 小时前
水趣钓鱼救生衣专利拆解:两级锁紧如何解决落水人衣分离
java·前端·网络
计算机魔术师10 小时前
Anthropic CEO突然喊踩刹车,OpenAI罕见力挺:AI这辆车不能只踩油门了
前端
wing9810 小时前
从codex转战workbuddy使用一周的感受
前端·人工智能·后端
EatFan10 小时前
Java接入支付宝 JSAPI 支付保姆教程(二):流程讲解与前后端代码讲解
前端·spring boot·后端·微信小程序·小程序·uni-app
梦想平凡11 小时前
百游棋牌源代码开发搭建教程(五):房间创建、座位分配与请求幂等实现
前端·javascript·数据库·源代码管理
IT_陈寒11 小时前
Redis的Set操作居然能把我的服务整挂了?
前端·人工智能·后端