简介:本文深入剖析 Android 自定义 View 中的文字跑马灯效果实现原理,从系统原生实现到自定义扩展方案,提供代码示例与性能优化建议。
文字跑马灯(Marquee)是移动端常见的交互效果,广泛应用于新闻标题展示、股票代码滚动、广告横幅等场景。其核心价值在于:
系统原生 TextView 的 ellipsize="marquee" 属性虽能实现基础效果,但存在动画生硬、控制能力弱等缺陷。自定义实现可突破这些限制,实现更灵活的控制。
<TextViewandroid:layout_width="200dp"android:layout_height="wrap_content"android:ellipsize="marquee"android:focusable="true"android:focusableInTouchMode="true"android:marqueeRepeatLimit="marquee_forever"android:singleLine="true"android:text="这是一段需要跑马灯效果的长文本" />
关键属性说明:
singleLine="true":强制单行显示(API 16+ 推荐使用 maxLines="1")marqueeRepeatLimit:控制滚动次数(marquee_forever 表示无限循环)系统通过 Marquee 类实现动画,核心流程:
scrollX)Choreographer 注册帧回调scrollX 实现平滑移动局限性分析:
class MarqueeTextView @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0) : AppCompatTextView(context, attrs, defStyleAttr) {private var scrollX = 0fprivate val scrollSpeed = 2f // 像素/帧private var isRunning = falseprivate var textWidth = 0fprivate var viewWidth = 0finit {isSingleLine = trueellipsize = null}override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec)textWidth = paint.measureText(text.toString())viewWidth = measuredWidth.toFloat()}override fun onDraw(canvas: Canvas) {if (textWidth > viewWidth && isRunning) {scrollX -= scrollSpeedif (abs(scrollX) > textWidth) {scrollX = viewWidth}canvas.save()canvas.translate(scrollX, 0f)super.onDraw(canvas)canvas.restore()invalidate()} else {super.onDraw(canvas)}}fun startMarquee() {isRunning = trueinvalidate()}fun stopMarquee() {isRunning = false}}
实现要点:
onMeasure 获取文本和视图宽度onDraw 中实现手动滚动逻辑invalidate() 触发持续重绘
class AnimatedMarqueeView @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0) : AppCompatTextView(context, attrs, defStyleAttr) {private var animator: ValueAnimator? = nullprivate var scrollX = 0finit {isSingleLine = trueellipsize = null}fun startMarquee(duration: Long = 5000) {stopMarquee()val textWidth = paint.measureText(text.toString())val viewWidth = width.toFloat()if (textWidth > viewWidth) {val distance = textWidth + viewWidthanimator = ValueAnimator.ofFloat(0f, -distance).apply {addUpdateListener { animation ->scrollX = animation.animatedValue as Floatinvalidate()}duration = durationrepeatCount = ValueAnimator.INFINITEinterpolator = LinearInterpolator()start()}}}fun stopMarquee() {animator?.cancel()animator = null}override fun onDraw(canvas: Canvas) {if (scrollX != 0f) {canvas.save()canvas.translate(scrollX, 0f)super.onDraw(canvas)canvas.restore()} else {super.onDraw(canvas)}}}
优势对比:
| 特性 | 基础实现方案 | 属性动画方案 |
|——————————|——————————|——————————|
| 性能开销 | 较高(持续invalidate) | 较低(动画系统优化) |
| 灵活性 | 中等 | 高(可配置插值器) |
| 复杂动画支持 | 困难 | 容易 |
// 缓存测量结果避免重复计算private val textWidthCache = mutableMapOf<String, Float>()override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec)val text = text.toString()textWidth = textWidthCache.getOrPut(text) {paint.measureText(text)}viewWidth = measuredWidth.toFloat()}
Canvas.clipRect() 限制绘制区域offscreenBufferonDraw 中创建对象
// 根据视图可见性控制动画override fun onWindowVisibilityChanged(visibility: Int) {super.onWindowVisibilityChanged(visibility)when (visibility) {View.VISIBLE -> startMarquee()else -> stopMarquee()}}// 结合ViewTreeObserver检测布局变化override fun onAttachedToWindow() {super.onAttachedToWindow()viewTreeObserver.addOnGlobalLayoutListener {// 布局变化后重新计算}}
enum class MarqueeDirection {LEFT, RIGHT, UP, DOWN}class DirectionalMarqueeView : AppCompatTextView {var direction: MarqueeDirection = MarqueeDirection.LEFTset(value) {field = valueresetAnimation()}// 实现对应方向的滚动逻辑...}
fun pauseMarquee() {if (animator?.isRunning == true) {animator?.pause()}}fun resumeMarquee() {animator?.resume()}
var scrollSpeed = 2fset(value) {field = value// 重新计算动画参数}// 在动画更新监听器中使用animator?.addUpdateListener {val progress = it.animatedFractionval currentSpeed = scrollSpeed * (1 + 0.5 * sin(progress * PI))// 根据进度动态调整速度}
资源管理:
onDetachedFromWindow() 中释放动画资源兼容性处理:
// 检测硬件加速状态fun isHardwareAccelerated(): Boolean {return context.resources?.configuration?.windowConfiguration?.windowConfiguration?.isHardwareAccelerated() ?: false}
测试要点:
替代方案考虑:
... 省略号Lottie 动画库RecyclerView + 自定义ItemDecoration原因分析:
invalidate() 调用解决方案:
// 使用postDelayed替代连续invalidateprivate fun smoothScroll() {postDelayed({scrollX -= scrollSpeedif (shouldContinueScrolling()) {invalidate()smoothScroll()}}, 16) // 接近60fps的间隔}
处理策略:
// 根据语言方向调整初始位置fun adjustForLocale() {val isRtl = layoutDirection == VIEW_LAYOUT_DIRECTION_RTLscrollX = if (isRtl) viewWidth else 0f}
协调方案:
// 使用动画监听器协调animator?.addListener(object : AnimatorListenerAdapter() {override fun onStart(animation: Animator) {// 暂停其他动画}override fun onEnd(animation: Animator) {// 恢复其他动画}})
自定义文字跑马灯的实现涉及测量、绘制、动画三大核心环节。通过系统原生方案的剖析,我们理解了其基本原理和局限性。自定义实现方案提供了更大的灵活性,但需要开发者自行处理性能优化和边缘情况。
未来发展方向:
RenderScript 实现GPU加速MotionLayout 实现复杂动画序列建议开发者根据实际需求选择实现方案:简单场景可使用系统属性,复杂交互需求推荐自定义实现。无论哪种方案,都应注重性能测试和兼容性验证,确保在不同设备和Android版本上的稳定表现。