🚗 写在前面
在车载中控屏(IVI)开发中,跑马灯(Marquee)是非常常见的交互组件------导航目的地、蓝牙音乐歌名、AI识别的乘客姓名+情绪标签......这些内容往往文本长度不定,一行显示不下,又需要 连续滚动 以呈现完整信息。
Android 原生 TextView 虽然自带 ellipsize="marquee" ,但它有几个致命问题:
- 需要 isFocused() 才能滚动 ------ 车载场景下焦点管理很混乱,很容易停止滚动
- 无法控制滚动速度和方向 ------ 只能匀速从右往左滚
- 不支持 Spannable 富文本颜色 ------ 原生跑马灯模式下 ForegroundColorSpan 经常失效
- 滚动条件苛刻 ------ setSingleLine(true) + setFocusableInTouchMode(true) + setMarqueeRepeatLimit(-1) 缺一不可
本文分享一个 基于 Handler 实现的自定义跑马灯控件 ,在车载项目中稳定运行数月。特点:
- ✅ 支持 Spannable 富文本 (括号内文本可变色)
- ✅ 3 种滚动模式: 左→右循环 / 右→左循环 / 来回弹跳
- ✅ 可配置滚动速度、帧率、文本间距
- ✅ 无需获取焦点,主动调用 startMarquee() 即可滚动
- ✅ 支持动态修改文本、自动重新测量
🎯 效果预览
- (Happy) 显示绿色, (Sad) 显示蓝色, (Calm) 显示灰色
- 整个文本在一行内循环滚动,无缝衔接
📐 核心设计思路
1. 为什么用 Handler,不用 Animation / ValueAnimator?
方案 优点 缺点 Handler + sendEmptyMessageDelayed 可控性强,可在任意节点暂停/恢复;可精确控制每帧逻辑(重新测量宽度、处理 Spannable);无内存泄漏隐患 需要自己写刷新逻辑 TranslateAnimation 简单 动画框架较重,每帧重绘整个 View,滚动切换边界不自然 ValueAnimator API 现代 回调在 Choreographer 线程,需手动切主线程;暂停/恢复不够灵活
车载场景选择 Handler 的另一个理由:当应用退到后台或进入驾驶模式(Doze Mode)时, post 队列会自动挂起,而我们可以在 onVisibilityChanged / onWindowFocusChanged 中主动停止消息,避免掉电。
2. 核心数据结构
3. 关键生命周期钩子
- onMeasure :获取 measuredWidth ,更新 viewWidth
- onSizeChanged :布局尺寸变化时,重新测量并 post 延迟启动
- onTextChanged :文本变化时,重置 scrollX=0 ,重新计算 textWidth
- onDraw :核心绘制方法,分段绘制带颜色的 Spannable 文本
💡 实现难点与踩坑
坑1:viewWidth 为 0 导致"不会滚动"
现象 :在 onCreate 中调用 marqueeTextView.setText(...) 后立刻 startMarquee() ,文本静止不动。
原因 : onMeasure 还没执行, getWidth() = 0 ,导致 textWidth > availableWidth 判断失效。
解决 : startMarquee() 内部用 view.post(Runnable) 延迟到下一个 UI 帧再启动,确保布局已完成。
坑2:Spannable 文本在自定义 onDraw 中丢失颜色
现象 :传了 SpannableStringBuilder (带 ForegroundColorSpan )给 setText() ,但 canvas.drawText(text, x, y, paint) 画出的仍是纯黑文本。
原因 : Canvas.drawText(String/CharSequence, ...) 只绘制基本字形 ,不解析 Spanned 中的 Span 标记。富文本绘制必须手动解析 Span 并分段调用 paint.setColor() 。
解决 :写一个 drawSpannableText() 方法,遍历所有 ForegroundColorSpan ,按 span.start/end 切割文本,逐段设置颜色并绘制。
坑3:滚动到底后"跳跃"不自然
方向1(左→右循环) :文本从右侧滚出, scrollX 一直递减。当 scrollX <= -(textWidth + spacing) 时,重置 scrollX = 0 ,产生无缝循环。
方向2(弹跳 bounce) :到达最左端反向,到达最右端再反向,模拟"来回弹"效果。
🛠️ 接入步骤(3步)
Step 1:将自定义属性加入 attrs.xml
Step 2:在布局 XML 中使用
Step 3:在 Activity/Fragment 中动态设置(可选)
运行时动态控制
⚠️ 使用注意事项
1. setText(SpannableStringBuilder) 不要 toString()
2. 文本不超长时也想滚动?显式调用 startMarquee()
shouldScroll 仅在 textWidth > viewWidth 时为 true。如果文本很短也想让它循环滚动(比如展示"动画效果"),直接 startMarquee() 即可------ onDraw 中 isScrolling 为 true 时会 强制 走自定义滚动绘制路径。
3. Activity 退到后台时建议停止滚动
(源码底部已注释 onVisibilityChanged 等生命周期钩子,可按需打开)
4. 滚动速度建议
- scrollSpeed = 1~3 像素/帧(60fps):适合公告类文本,看起来"舒缓"
- scrollSpeed = 3~6 :适合导航/音乐标题类,看起来"有动感"
- 超过 8 可能会有跳帧感,建议同时调高 frameRate
5. minSdk 要求
本控件使用了 Paint.measureText(CharSequence, int, int) (API 21+)和 AppCompatTextView (AndroidX), 建议 minSdk >= 21 。项目中 minSdk = 31 (Android 12)完全无压力。
6. 性能优化建议
- TextView.getPaint() 返回的是内部 TextPaint 对象,可复用,无需每次 new TextPaint()
- onDraw 中避免 new Rect() 、 new Paint() 等对象分配,防止 GC 卡顿------源码中 Rect 已改为在 onDraw 中局部创建(文本滚动每帧 1 次分配,对现代系统可忽略;极端场景可提到成员变量)
- 如滚动内容 频繁变化 (比如每秒更新一次乘客列表),可加防抖: removeCallbacks(postStartRunnable); postDelayed(postStartRunnable, 150)
📦 适用场景
场景 推荐配置 车载 IVI 顶部通知栏 DIRECTION_LEFT_TO_RIGHT ,速度 2.0,间距 100dp 蓝牙音乐/Spotify 歌名 DIRECTION_LEFT_TO_RIGHT ,速度 1.5 AI 人脸识别 + 情绪标签 DIRECTION_LEFT_TO_RIGHT ,Spannable 富文本 大屏促销信息/欢迎语 DIRECTION_BOUNCE ,速度 3.0,有"动感" RTL 语言(阿拉伯语等) DIRECTION_RIGHT_TO_LEFT
📝 完整源码
1)attrs.xml(放在 res/values/ 目录)
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="HandlerMarqueeTextView">
<!-- 滚动速度(像素/帧) -->
<attr name="marqueeSpeed" format="float" />
<!-- 滚动方向 -->
<attr name="marqueeDirection" format="enum">
<enum name="left_to_right" value="0" />
<enum name="right_to_left" value="1" />
<enum name="bounce" value="2" />
</attr>
<!-- 文本间距 -->
<attr name="marqueeSpacing" format="dimension" />
<!-- 是否自动开始 -->
<attr name="marqueeAutoStart" format="boolean" />
<!-- 帧率(FPS) -->
<attr name="frameRate" format="integer" />
</declare-styleable>
</resources>
2)HandlerMarqueeTextView.java(完整代码)
/**
* 使用 Handler 实现的跑马灯 TextView
* 高效、可控、兼容性好
*/
public class HandlerMarqueeTextView extends AppCompatTextView {
// 动画相关
private float scrollX = 0f;
private float scrollSpeed = 2f; // 滚动速度(像素/帧)
private int direction = DIRECTION_LEFT_TO_RIGHT;
private boolean isScrolling = false;
private boolean isPaused = false;
// 文本测量
private float textWidth = 0f;
private int viewWidth = 0;
private boolean shouldScroll = false;
// 间距
private float spacing = 100f; // 文本之间的间距
// Handler 相关
private static final int MSG_SCROLL = 1;
private int frameDelay = 16; // 约60fps (1000ms/60 ≈ 16ms)
// 延迟启动的 Runnable,确保布局完成后再判断是否滚动
private final Runnable mDeferredStartRunnable = this::checkAndStartScroll;
private Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
if (msg.what == MSG_SCROLL) {
if (isScrolling && !isPaused) {
updateScrollPosition();
invalidate();
// 发送下一帧消息
sendScrollMessage();
}
}
}
};
// 方向常量
public static final int DIRECTION_LEFT_TO_RIGHT = 0;
public static final int DIRECTION_RIGHT_TO_LEFT = 1;
public static final int DIRECTION_BOUNCE = 2;
public HandlerMarqueeTextView(Context context) {
this(context, null);
}
public HandlerMarqueeTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public HandlerMarqueeTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setupAttributes(attrs);
setupView();
}
private void setupAttributes(AttributeSet attrs) {
if (attrs == null) return;
TypedArray typedArray = getContext().obtainStyledAttributes(
attrs,
R.styleable.HandlerMarqueeTextView
);
try {
scrollSpeed = typedArray.getFloat(
R.styleable.HandlerMarqueeTextView_marqueeSpeed,
2f
);
direction = typedArray.getInt(
R.styleable.HandlerMarqueeTextView_marqueeDirection,
DIRECTION_LEFT_TO_RIGHT
);
spacing = typedArray.getDimension(
R.styleable.HandlerMarqueeTextView_marqueeSpacing,
100f
);
frameDelay = typedArray.getInteger(
R.styleable.HandlerMarqueeTextView_frameRate,
60
);
if (frameDelay > 0) {
frameDelay = 1000 / frameDelay;
} else {
frameDelay = 16;
}
boolean autoStart = typedArray.getBoolean(
R.styleable.HandlerMarqueeTextView_marqueeAutoStart,
true
);
if (autoStart) {
startMarquee();
}
} finally {
typedArray.recycle();
}
}
private void setupView() {
// 确保单行显示
setMaxLines(1);
setEllipsize(null);
setSingleLine(true);
// 设置 Gravity 为 LEFT,便于计算
setGravity(Gravity.START | Gravity.CENTER_VERTICAL);
}
private void sendScrollMessage() {
if (handler.hasMessages(MSG_SCROLL)) {
handler.removeMessages(MSG_SCROLL);
}
handler.sendEmptyMessageDelayed(MSG_SCROLL, frameDelay);
}
@Override
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
super.onTextChanged(text, start, lengthBefore, lengthAfter);
resetScrollPosition();
checkShouldScroll();
// 文本变更后延迟检查布局,确保 viewWidth 有效
removeCallbacks(mDeferredStartRunnable);
post(mDeferredStartRunnable);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
viewWidth = w;
checkShouldScroll();
// 尺寸变更后延迟检查滚动,确保布局完成
removeCallbacks(mDeferredStartRunnable);
post(mDeferredStartRunnable);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth = getMeasuredWidth();
if (measuredWidth > 0) {
viewWidth = measuredWidth;
}
checkShouldScroll();
}
private void checkShouldScroll() {
checkShouldScroll(true);
}
/**
* @param useViewWidthCache true=使用缓存的viewWidth, false=使用getWidth()获取当前实际宽度
*/
private void checkShouldScroll(boolean useViewWidthCache) {
CharSequence text = getText();
if (text == null || text.length() == 0) {
shouldScroll = false;
return;
}
// 计算文本宽度
TextPaint paint = getPaint();
textWidth = paint.measureText(text, 0, text.length());
// 优先使用 getWidth() 获取实际宽度(在视图已布局完成后)
int currentViewWidth = useViewWidthCache ? viewWidth : getWidth();
if (currentViewWidth <= 0) {
currentViewWidth = getWidth();
}
if (currentViewWidth <= 0) {
currentViewWidth = viewWidth;
}
viewWidth = currentViewWidth;
// 判断是否需要滚动
int availableWidth = viewWidth - getPaddingLeft() - getPaddingRight();
if (availableWidth <= 0) {
// 视图宽度还未确定,暂时认为需要滚动(文本存在)
shouldScroll = true;
return;
}
shouldScroll = textWidth > availableWidth;
}
/**
* 检查布局并启动滚动(确保在 viewWidth 有效后再判断)
*/
private void checkAndStartScroll() {
viewWidth = getWidth();
if (viewWidth > 0) {
checkShouldScroll(false);
}
// 如果判定需要滚动但动画还未启动,则启动
if (shouldScroll && !isScrolling) {
isScrolling = true;
resetScrollPosition();
sendScrollMessage();
}
invalidate();
}
private void resetScrollPosition() {
int curWidth = getWidth();
int width = curWidth > 0 ? curWidth : viewWidth;
switch (direction) {
case DIRECTION_LEFT_TO_RIGHT:
scrollX = 0f;
break;
case DIRECTION_RIGHT_TO_LEFT:
scrollX = width;
break;
case DIRECTION_BOUNCE:
scrollX = 0f;
break;
}
}
private void updateScrollPosition() {
// 使用实时宽度(防止 cached viewWidth 过期)
int curWidth = getWidth();
if (curWidth > 0) {
viewWidth = curWidth;
}
// 确保 textWidth 已计算(可能还未布局时为0)
if (textWidth <= 0) {
checkShouldScroll(false);
if (textWidth <= 0) return;
}
switch (direction) {
case DIRECTION_LEFT_TO_RIGHT:
scrollX -= scrollSpeed;
float totalWidth = textWidth + spacing;
if (-scrollX >= totalWidth) {
scrollX = 0f; // 重新开始
}
break;
case DIRECTION_RIGHT_TO_LEFT:
scrollX += scrollSpeed;
float viewW = viewWidth > 0 ? viewWidth : getWidth();
if (scrollX >= viewW + textWidth) {
scrollX = -textWidth; // 重新开始
}
break;
case DIRECTION_BOUNCE:
float bounceViewW = viewWidth > 0 ? viewWidth : getWidth();
if (scrollSpeed > 0) {
// 向右移动
scrollX -= scrollSpeed;
if (-scrollX >= textWidth - bounceViewW) {
// 到达最右,反向
scrollSpeed = -scrollSpeed;
}
} else {
// 向左移动
scrollX -= scrollSpeed; // scrollSpeed为负数
if (scrollX >= 0) {
// 到达最左,反向
scrollSpeed = -scrollSpeed;
}
}
break;
}
}
@Override
protected void onDraw(Canvas canvas) {
// 实时更新视图宽度
int currentWidth = getWidth();
if (currentWidth > 0) {
viewWidth = currentWidth;
}
// 如果isScrolling为true(用户已启动滚动)或shouldScroll为true,都走自定义滚动路径
if (!isScrolling && !shouldScroll) {
super.onDraw(canvas);
return;
}
// 实时检查shouldScroll(以防文本或宽度变化)
if (viewWidth > 0 && !shouldScroll) {
checkShouldScroll(false);
}
CharSequence text = getText();
if (text == null || text.length() == 0) return;
// 保存画布状态
canvas.save();
// 裁剪画布,防止文本绘制到 padding 区域外
Rect clipRect = new Rect(
getPaddingLeft(),
getPaddingTop(),
getWidth() - getPaddingRight(),
getHeight() - getPaddingBottom()
);
canvas.clipRect(clipRect);
// 绘制文本
float textY = getBaseline();
switch (direction) {
case DIRECTION_LEFT_TO_RIGHT:
drawLeftToRight(canvas, text, textY);
break;
case DIRECTION_RIGHT_TO_LEFT:
drawRightToLeft(canvas, text, textY);
break;
case DIRECTION_BOUNCE:
drawBounce(canvas, text, textY);
break;
}
// 恢复画布状态
canvas.restore();
}
private void drawLeftToRight(Canvas canvas, CharSequence text, float textY) {
float currentX = getPaddingLeft() + scrollX;
// 绘制多个文本以实现循环效果
while (currentX < getWidth()) {
drawSpannableText(canvas, text, currentX, textY);
currentX += textWidth + spacing;
}
}
private void drawRightToLeft(Canvas canvas, CharSequence text, float textY) {
float currentX = scrollX;
// 绘制多个文本以实现循环效果
while (currentX + textWidth > 0) {
drawSpannableText(canvas, text, currentX, textY);
currentX -= (textWidth + spacing);
}
}
private void drawBounce(Canvas canvas, CharSequence text, float textY) {
drawSpannableText(canvas, text, getPaddingLeft() + scrollX, textY);
}
/**
* 绘制支持 ForegroundColorSpan 的文本
* @return 绘制文本的总宽度
*/
private float drawSpannableText(Canvas canvas, CharSequence text, float x, float textY) {
TextPaint paint = getPaint();
int defaultColor = getCurrentTextColor();
float currentX = x;
if (text instanceof Spanned) {
Spanned spanned = (Spanned) text;
android.text.style.ForegroundColorSpan[] colorSpans =
spanned.getSpans(0, text.length(), android.text.style.ForegroundColorSpan.class);
if (colorSpans.length > 0) {
// 按 span 起始位置排序
java.util.Arrays.sort(colorSpans, (a, b) ->
spanned.getSpanStart(a) - spanned.getSpanStart(b));
int lastPos = 0;
for (android.text.style.ForegroundColorSpan span : colorSpans) {
int start = spanned.getSpanStart(span);
int end = spanned.getSpanEnd(span);
// 绘制 span 之前的普通文本
if (start > lastPos) {
paint.setColor(defaultColor);
String plainText = text.subSequence(lastPos, start).toString();
canvas.drawText(plainText, currentX, textY, paint);
currentX += paint.measureText(plainText);
}
// 绘制带颜色的 span 文本
paint.setColor(span.getForegroundColor());
String spanText = text.subSequence(start, end).toString();
canvas.drawText(spanText, currentX, textY, paint);
currentX += paint.measureText(spanText);
lastPos = end;
}
// 绘制最后一个 span 之后的剩余文本
if (lastPos < text.length()) {
paint.setColor(defaultColor);
String plainText = text.subSequence(lastPos, text.length()).toString();
canvas.drawText(plainText, currentX, textY, paint);
currentX += paint.measureText(plainText);
}
// 恢复默认颜色
paint.setColor(defaultColor);
return currentX - x;
}
}
// 回退:绘制普通文本
paint.setColor(defaultColor);
String plainText = text.toString();
canvas.drawText(plainText, currentX, textY, paint);
return paint.measureText(plainText);
}
/**
* 开始跑马灯动画
*/
public void startMarquee() {
if (isScrolling) return;
isScrolling = true;
isPaused = false;
// 延迟启动,确保视图布局完成后获取到正确的宽度
removeCallbacks(mDeferredStartRunnable);
post(() -> {
viewWidth = getWidth();
checkShouldScroll(false);
resetScrollPosition();
sendScrollMessage();
invalidate();
});
}
/**
* 暂停跑马灯动画
*/
public void pauseMarquee() {
isPaused = true;
if (handler.hasMessages(MSG_SCROLL)) {
handler.removeMessages(MSG_SCROLL);
}
}
/**
* 继续跑马灯动画
*/
public void resumeMarquee() {
isPaused = false;
if (!isScrolling) {
startMarquee();
} else {
sendScrollMessage();
}
}
/**
* 停止跑马灯动画
*/
public void stopMarquee() {
isScrolling = false;
isPaused = false;
if (handler.hasMessages(MSG_SCROLL)) {
handler.removeMessages(MSG_SCROLL);
}
resetScrollPosition();
invalidate();
}
/**
* 设置滚动速度
*/
public void setMarqueeSpeed(float speed) {
if (speed < 0) speed = 1f;
this.scrollSpeed = speed;
}
/**
* 获取当前滚动速度
*/
public float getMarqueeSpeed() {
return scrollSpeed;
}
/**
* 设置滚动方向
*/
public void setMarqueeDirection(int direction) {
if (this.direction != direction) {
this.direction = direction;
resetScrollPosition();
invalidate();
}
}
/**
* 获取当前滚动方向
*/
public int getMarqueeDirection() {
return direction;
}
/**
* 设置文本间距
*/
public void setMarqueeSpacing(float spacing) {
this.spacing = spacing;
invalidate();
}
/**
* 获取文本间距
*/
public float getMarqueeSpacing() {
return spacing;
}
/**
* 设置帧率(FPS)
*/
public void setFrameRate(int fps) {
if (fps < 1) fps = 1;
if (fps > 120) fps = 120;
frameDelay = 1000 / fps;
// 如果正在滚动,重新设置延迟
if (isScrolling && !isPaused) {
pauseMarquee();
resumeMarquee();
}
}
/**
* 获取当前帧率
*/
public int getFrameRate() {
return frameDelay > 0 ? 1000 / frameDelay : 60;
}
/**
* 是否正在滚动
*/
public boolean isMarqueeRunning() {
return isScrolling && !isPaused;
}
}
使用方法大概如下:
private void setEmote() {
String[] names = {"AliceAliceAliceAlice", "BobBob", "CharlieCharlie"};
String[] emotions = {"Happy", "Sad", "Angry"};
int[] emotionColors = {Color.GREEN, Color.BLUE, Color.RED};
updateRecognitionResultWithEmotion(names, emotions, emotionColors);
}
/**
* 更新识别结果(带颜色的名字和情绪)
*/
public void updateRecognitionResultWithEmotion(String[] names, String[] emotions, int[] emotionColors) {
mMainHandler.post(() -> {
SpannableStringBuilder builder = new SpannableStringBuilder();
for (int i = 0; i < names.length; i++) {
if (i > 0) {
builder.append("、");
}
builder.append(names[i]);
int start = builder.length();
builder.append("(");
builder.append(emotions[i]);
builder.append(")");
int end = builder.length();
builder.setSpan(new ForegroundColorSpan(emotionColors[i]), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
marqueeTextView.setText(builder);
if (!marqueeTextView.isMarqueeRunning()) {
marqueeTextView.startMarquee();
}
});
}
🔚 小结
本文的 HandlerMarqueeTextView 解决了车载开发中跑马灯的几个 核心痛点 :
需求 原生 TextView HandlerMarqueeTextView 无需焦点即可滚动 ❌ ✅ 可控制滚动方向与速度 ❌ ✅ 3 种方向 + 无级调速 Spannable 富文本颜色 ❌ 经常失效 ✅ 手动解析 ForegroundColorSpan 可动态启停 ❌ 不可控 ✅ start/pause/resume/stop 生命周期管理 ❌ 滚动到后台会引起掉电 ✅ 可在 onPause/onResume 中挂钩
如果您的项目是车载 IVI、车机 Launcher、音乐播放器,或者有任何**"长文本需滚动 + 局部变色"**需求,直接把上面两份源码拷贝到工程,替换 com.neusoft.ips.util.view 为您自己的包名即可直接使用。
有问题欢迎在评论区交流 👍🏻