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)
    }
}
相关推荐
Emily156853598702 小时前
Emerson 1C31129G03 Analog Input Module
java·开发语言·前端·plc·emerson·1c31129g03·input module
To_OC7 小时前
面试被问了三回三栏布局,这次我终于把 BFC 那层窗户纸捅破了
前端·css·面试
To_OC7 小时前
啃完 TS 工具类型我发现:Pick 和 Omit 原来就是一层窗户纸
前端·面试·typescript
风月说与山鬼9 小时前
三、大括号语法
前端·react.js
kyriewen9 小时前
我把最常踩的8个CORS跨域报错整理了一遍——第8个去年还不存在
前端·javascript
Csvn9 小时前
🕰️ 闭包 + setTimeout 的 5 个经典陷阱:为什么定时器看到的永远不是最新的值?
前端
lilian23310 小时前
Harmony os 技术实战|拼豆制图27:用单字符编码承载 50 张 70×70 图纸
前端·数据库·华为·harmonyos
CarIise10 小时前
CSS选择器与样式关联
前端·css·tensorflow
里欧跑得慢11 小时前
CSS 模块化架构的演进:BEM、CSS Modules 到 CSS-in-JS 的反思
前端·css·flutter·web·css-in-js