前言
一个可以横向滚动和纵向滚动的自定义文字跑马灯View,支持水平和垂直滚动、多段文本展示、点击事件回调等功能。
该View 由 ScrollTextView,改版而来,效果如下:
1. 功能介绍
ScrollTextView 是基于 SurfaceView 的自定义视图,功能包括:
1.1 水平滚动:
-
文本从右向左滚动,支持多段文本循环展示。
-
每段文本滚动结束后可以设置停留时间。
1.2 垂直滚动:
-
文本从下向上滚动,支持多段文本循环展示。
-
如果文本长度超过视图宽度,会自动触发水平滚动,直到文本完全显示。
1.3 点击事件:
-
支持点击暂停/恢复滚动。
-
提供点击事件回调,返回当前滚动文本的下标和内容。
1.4 自定义属性:
- 支持通过 XML 或代码设置滚动速度、文本颜色、字体大小等属性。
2. 关键技术点解析
2.1. 使用 SurfaceView 实现高效绘制
SurfaceView 是一个独立的绘图表面,适合需要频繁更新的 UI 场景(如滚动文本)。与普通 View 不同,SurfaceView 的绘制在非 UI 线程中进行,因此不会阻塞主线程。
java
public class ScrollTextView extends SurfaceView implements SurfaceHolder.Callback {
private SurfaceHolder surfaceHolder;
public ScrollTextView(Context context, AttributeSet attrs) {
super(context, attrs);
surfaceHolder = getHolder();
surfaceHolder.addCallback(this);
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
// 启动滚动线程
new Thread(new ScrollTextThread()).start();
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
// 停止滚动线程
stopScroll = true;
}
}
2.2. 水平滚动逻辑
水平滚动的核心是通过不断更新文本的 X 坐标,实现从右向左的滚动效果。
水平滚动即从初始状态右侧View之外滑入,到左侧完全滑出为止。
bash
if (isHorizontal) { // 水平滚动逻辑
if (pauseScroll) {// 暂停
sleep(1000);
continue;
}
drawText(viewWidth - textX, textY);// 绘制文本
textX += speed;// 文本滚动距离
if (textX > viewWidthPlusTextLength) {// 文本从右至左滚动超出屏幕
currentTextIndex++;
textX = 0;
--needScrollTimes;
if (currentTextIndex >= textList.size()) {// 文字循环展示
if (isScrollForever) {
currentTextIndex = 0;
} else {
stopScroll = true;
break;
}
}
measureTextParams(false);
}
}
2.3. 垂直滚动逻辑
垂直滚动的核心是通过不断更新文本的 Y 坐标,实现从下向上的滚动效果。如果文本长度超过视图宽度,会自动触发水平滚动。
- 阶段1:
-
文本从视图底部之外滚动到基线位置。
-
如果文本超长,准备触发水平滚动。
- 阶段2:
-
(若文本过长)文本从右向左水平滚动,直到文本末尾完全显示。
-
水平滚动完成后,暂停 stayTimes 毫秒。
- 阶段3:
-
文本从基线位置继续向上滚动,直到完全离开视图顶部。
-
切换到下一段文本,重复上述过程。
核心代码:
java
private void drawVerticalScroll() {
if (currentTextIndex >= textList.size()) return;
// 计算基线
String text = textList.get(currentTextIndex);
float fontHeight = paint.getFontMetrics().bottom - paint.getFontMetrics().top;
Paint.FontMetrics fontMetrics = paint.getFontMetrics();
float distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;
float baseLine = (float) viewHeight / 2 + distance;
// 是否需要水平滚动
boolean needHorizontalScroll = paint.measureText(text) > viewWidth;
// 水平滚动偏移
float horizontalOffset = 0;
// --- 阶段1:垂直滚动到基线位置(精准停止)---
float currentVerticalPos = viewHeight + fontHeight; // 初始位置
while (currentVerticalPos > baseLine) {
if (stopScroll || isSetNewText) return;
if (pauseScroll) {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
continue;
}
// 计算下一步位置,避免越过基线
float nextPos = currentVerticalPos - 3;
if (nextPos < baseLine) nextPos = baseLine; // 确保不会越过基线
drawText(0, nextPos);
currentVerticalPos = nextPos;
}
// --- 阶段1暂停:基线位置暂停 ---
if (needHorizontalScroll) {
sleep(1000);
} else if (!stopScroll && !isSetNewText) {
sleep(stayTimes); // 原基线位置暂停
}
// --- 阶段2:水平滚动(垂直位置固定为基线)---
if (needHorizontalScroll) {
// 计算最大水平滚动偏移
float maxHorizontalOffset = -(paint.measureText(text) - viewWidth);
// 水平滚动
while (horizontalOffset > maxHorizontalOffset) {
if (stopScroll || isSetNewText) return;
horizontalOffset -= speed;
drawText(horizontalOffset, baseLine);
}
// --- 阶段2暂停:水平滚动完成后暂停 ---
if (!stopScroll && !isSetNewText) {
sleep(stayTimes); // 新增水平滚动完成后的暂停
}
}
// --- 阶段3:继续垂直滚动到视图外 ---
for (float i = baseLine; i > -fontHeight; i -= 3) {
if (stopScroll || isSetNewText) return;
if (pauseScroll) {
sleep(1000);
continue;
}
drawText(needHorizontalScroll ? horizontalOffset : 0, i);
}
// 切换到下一个文本
currentTextIndex++;
if (currentTextIndex >= textList.size()) {
if (isScrollForever) currentTextIndex = 0;
else stopScroll = true;
}
}
2.4. 点击事件处理
java
@Override
public boolean onTouchEvent(MotionEvent event) {
if (clickEnable && event.getAction() == MotionEvent.ACTION_DOWN) {
pauseScroll = !pauseScroll; // 切换暂停状态
if (onTextClickListener != null) {
// 回调当前滚动文本的下标和内容
onTextClickListener.onTextClick(currentTextIndex, textList.get(currentTextIndex));
}
}
return true;
}
3. 自定义属性
xml
<declare-styleable name="ScrollTextView">
<attr name="clickEnable" format="boolean" />
<attr name="isHorizontal" format="boolean" />
<attr name="speed" format="integer" />
<attr name="stTextColor" format="color" />
<attr name="stTextSize" format="dimension" />
<attr name="times" format="integer" />
<attr name="isScrollForever" format="boolean" />
</declare-styleable>
4. 完整代码
java
public class ScrollTextView extends SurfaceView implements SurfaceHolder.Callback {
private final String TAG = "ScrollTextView";
private SurfaceHolder surfaceHolder;// SurfaceHolder管理SurfaceView的绘制表面
private Paint paint = null;
private boolean stopScroll = false;// 停止滚动
private boolean pauseScroll = false;// 暂停滚动
private boolean clickEnable = false;// 是否启用点击事件
public boolean isHorizontal = true;// 是否水平滚动
private int speed = 4;// 滚动速度
private long stayTimes = 5_000;// 横向滚动每段文字结束后的停留时间
private List<String> textList = new ArrayList<>(); // 存储多个文本内容
private int currentTextIndex = 0; // 当前滚动的文本索引
private float textSize = 20f;// 文本字体大小
private int textColor;// 文本颜色
private int textBackColor = 0x00000000;// 文本背景颜色
private int needScrollTimes = Integer.MAX_VALUE;// 需要滚动的次数
private int viewWidth = 0, viewHeight = 0;// 视图的宽度和高度
private float textWidth = 0f, textX = 0f, textY = 0f;// 文本的宽度、X坐标、Y坐标
private float viewWidthPlusTextLength = 0.0f;// 视图宽度加上文本长度
boolean isSetNewText = false;// 是否设置了新的文本
boolean isScrollForever = true;// 是否无限滚动
private Canvas canvas;// 画布,用于绘制文本
private OnTextClickListener onTextClickListener;// 点击事件监听器
public interface OnTextClickListener {
void onTextClick(int index, String text);
}
public void setOnTextClickListener(OnTextClickListener listener) {
this.onTextClickListener = listener;
}
public ScrollTextView(Context context) {
super(context);
}
public ScrollTextView(Context context, AttributeSet attrs) {
super(context, attrs);
surfaceHolder = this.getHolder(); //get The surface holder
surfaceHolder.addCallback(this);
paint = new Paint();
TypedArray arr = getContext().obtainStyledAttributes(attrs, R.styleable.ScrollTextView);
clickEnable = arr.getBoolean(R.styleable.ScrollTextView_clickEnable, clickEnable);
isHorizontal = arr.getBoolean(R.styleable.ScrollTextView_isHorizontal, isHorizontal);
speed = arr.getInteger(R.styleable.ScrollTextView_speed, speed);
textColor = arr.getColor(R.styleable.ScrollTextView_stTextColor, Color.BLACK);
textSize = arr.getDimension(R.styleable.ScrollTextView_stTextSize, textSize);
needScrollTimes = arr.getInteger(R.styleable.ScrollTextView_times, Integer.MAX_VALUE);
isScrollForever = arr.getBoolean(R.styleable.ScrollTextView_isScrollForever, true);
paint.setColor(textColor);
paint.setTextSize(textSize);
paint.setFlags(Paint.ANTI_ALIAS_FLAG);
paint.setAntiAlias(true);
paint.setFilterBitmap(true);
setZOrderOnTop(true); //控制表面视图的表面位于其窗口的顶部。
getHolder().setFormat(PixelFormat.TRANSLUCENT);
setFocusable(true);
arr.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int mHeight = getFontHeight(textSize); //实际的视图高
viewWidth = MeasureSpec.getSize(widthMeasureSpec);
viewHeight = MeasureSpec.getSize(heightMeasureSpec);
// 当布局的宽度或高度是wrap_content,应该初始化ScrollTextView的宽度/高度
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(viewWidth, mHeight);
viewHeight = mHeight;
} else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(viewWidth, viewHeight);
} else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
setMeasuredDimension(viewWidth, mHeight);
viewHeight = mHeight;
}
}
/**
* surfaceChanged
*/
@Override
public void surfaceChanged(SurfaceHolder arg0, int arg1, int arg2, int arg3) {
Log.d(TAG, "arg0:" + arg0.toString() + " arg1:" + arg1 + " arg2:" + arg2 + " arg3:" + arg3);
}
/**
* 界面出现,初始化一个新的滚动线程。
*
* @param holder holder
*/
@Override
public void surfaceCreated(SurfaceHolder holder) {
stopScroll = false;
new Thread(new ScrollTextThread()).start();
Log.d(TAG, "ScrollTextTextView is created");
}
/**
* 界面退出,视图消失,自动调用
*
* @param arg0 SurfaceHolder
*/
@Override
public void surfaceDestroyed(SurfaceHolder arg0) {
synchronized (this) {
stopScroll = true;
}
Log.d(TAG, "ScrollTextTextView is destroyed");
}
private int getFontHeight(float fontSize) {
Paint paint = new Paint();
paint.setTextSize(fontSize);
Paint.FontMetrics fm = paint.getFontMetrics();
return (int) Math.ceil(fm.descent - fm.ascent);
}
public int getBackgroundColor() {
return textBackColor;
}
public void setTextBackColor(int color) {
this.setBackgroundColor(color);
this.textBackColor = color;
}
public int getTextBackColor() {
return textBackColor;
}
public int getSpeed() {
return speed;
}
public int getCurrentTextIndex() {
return currentTextIndex;
}
public void setTimes(int times) {
if (times <= 0) {
throw new IllegalArgumentException("times was invalid integer, it must between > 0");
} else {
needScrollTimes = times;
isScrollForever = false;
}
}
/**
* 纵向滚动设置每段文字的停留时间
* @param stayTimes
*/
public void setStayTimes(long stayTimes) {
this.stayTimes = stayTimes;
}
public long getStayTimes() {
return stayTimes;
}
public void setTextSize(float textSizeTem) {
if (textSizeTem < 20) {
throw new IllegalArgumentException("textSize must > 20");
} else if (textSizeTem > 900) {
throw new IllegalArgumentException("textSize must < 900");
} else {
this.textSize = sp2px(getContext(), textSizeTem);
//重新设置Size
paint.setTextSize(textSize);
//视图区域也要改变
measureTextParams(true);
//实际的视图高
int mHeight = getFontHeight(textSizeTem);
android.view.ViewGroup.LayoutParams lp = this.getLayoutParams();
lp.width = viewWidth;
lp.height = dip2px(this.getContext(), mHeight);
this.setLayoutParams(lp);
isSetNewText = true;
}
}
private int dip2px(Context context, float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
private int sp2px(Context context, float spValue) {
float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
return (int) (spValue * fontScale + 0.5f);
}
public int px2sp(Context context, float pxValue) {
float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
return (int) (pxValue / fontScale + 0.5f);
}
public void setHorizontal(boolean horizontal) {
isHorizontal = horizontal;
}
// 设置多个文本内容
public void setTextList(List<String> textList) {
this.textList = textList;
this.currentTextIndex = 0;
isSetNewText = true;
stopScroll = false;
measureTextParams(true);
}
public void setTextColor(@ColorInt int color) {
textColor = color;
paint.setColor(textColor);
}
public int getTextColor() {
return textColor;
}
/**
* 设置滚动速度
*
* @param speed SCROLL SPEED [4,14] ///// 0?
*/
public void setSpeed(int speed) {
if (speed > 14 || speed < 4) {
throw new IllegalArgumentException("Speed was invalid integer, it must between 4 and 14");
} else {
this.speed = speed;
}
}
/**
* 设置是否永远滚动文本
*/
public void setScrollForever(boolean scrollForever) {
isScrollForever = scrollForever;
}
public boolean isPauseScroll() {
return pauseScroll;
}
public void setPauseScroll(boolean pauseScroll) {
this.pauseScroll = pauseScroll;
}
public boolean isClickEnable() {
return clickEnable;
}
public void setClickEnable(boolean clickEnable) {
this.clickEnable = clickEnable;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!clickEnable) {
return true;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
pauseScroll = !pauseScroll;
if (onTextClickListener != null) {
onTextClickListener.onTextClick(currentTextIndex, textList.get(currentTextIndex));
}
break;
}
return true;
}
private void drawVerticalScroll() {
if (currentTextIndex >= textList.size()) return;
// 计算基线
String text = textList.get(currentTextIndex);
float fontHeight = paint.getFontMetrics().bottom - paint.getFontMetrics().top;
Paint.FontMetrics fontMetrics = paint.getFontMetrics();
float distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;
float baseLine = (float) viewHeight / 2 + distance;
// 是否需要水平滚动
boolean needHorizontalScroll = paint.measureText(text) > viewWidth;
// 水平滚动偏移
float horizontalOffset = 0;
// --- 阶段1:垂直滚动到基线位置(精准停止)---
float currentVerticalPos = viewHeight + fontHeight; // 初始位置
while (currentVerticalPos > baseLine) {
if (stopScroll || isSetNewText) return;
if (pauseScroll) {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
continue;
}
// 计算下一步位置,避免越过基线
float nextPos = currentVerticalPos - 3;
if (nextPos < baseLine) nextPos = baseLine; // 确保不会越过基线
drawText(0, nextPos);
currentVerticalPos = nextPos;
}
// --- 阶段1暂停:基线位置暂停 ---
if (needHorizontalScroll) {
sleep(1000);
} else if (!stopScroll && !isSetNewText) {
sleep(stayTimes); // 原基线位置暂停
}
// --- 阶段2:水平滚动(垂直位置固定为基线)---
if (needHorizontalScroll) {
// 计算最大水平滚动偏移
float maxHorizontalOffset = -(paint.measureText(text) - viewWidth);
// 水平滚动
while (horizontalOffset > maxHorizontalOffset) {
if (stopScroll || isSetNewText) return;
horizontalOffset -= speed;
drawText(horizontalOffset, baseLine);
}
// --- 阶段2暂停:水平滚动完成后暂停 ---
if (!stopScroll && !isSetNewText) {
sleep(stayTimes); // 新增水平滚动完成后的暂停
}
}
// --- 阶段3:继续垂直滚动到视图外 ---
for (float i = baseLine; i > -fontHeight; i -= 3) {
if (stopScroll || isSetNewText) return;
if (pauseScroll) {
sleep(1000);
continue;
}
drawText(needHorizontalScroll ? horizontalOffset : 0, i);
}
// 切换到下一个文本
currentTextIndex++;
if (currentTextIndex >= textList.size()) {
if (isScrollForever) currentTextIndex = 0;
else stopScroll = true;
}
}
/**
* 绘制文本
*/
private synchronized void drawText(float x, float y) {
try {
canvas = surfaceHolder.lockCanvas();
if (canvas == null) return;
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
canvas.drawText(textList.get(currentTextIndex), x, y, paint);
} finally {
if (canvas != null) {
surfaceHolder.unlockCanvasAndPost(canvas);
}
}
}
@Override
protected void onVisibilityChanged(View changedView, int visibility) {
super.onVisibilityChanged(changedView, visibility);
this.setVisibility(visibility);
}
// 测量文本参数
private void measureTextParams(boolean isInitialSetup) {
if (currentTextIndex >= textList.size()) return;
textWidth = paint.measureText(textList.get(currentTextIndex));
viewWidthPlusTextLength = viewWidth + textWidth;
if (isInitialSetup) {
textX = viewWidth - viewWidth / 2; // 第一次创建 ,默认从居中的位置开始滚动
Paint.FontMetrics fm = paint.getFontMetrics();
float distance = (fm.bottom - fm.top) / 2 - fm.bottom;
textY = viewHeight / 2 + distance;
}
}
/**
* Scroll thread
*/
class ScrollTextThread implements Runnable {
@Override
public void run() {
measureTextParams(true);
while (!stopScroll && surfaceHolder != null && !Thread.currentThread().isInterrupted()) {
if (isHorizontal) { // 水平滚动逻辑
if (pauseScroll) {// 暂停
sleep(1000);
continue;
}
drawText(viewWidth - textX, textY);// 绘制文本
textX += speed;// 文本滚动距离
if (textX > viewWidthPlusTextLength) {// 文本从右至左滚动超出屏幕
currentTextIndex++;
textX = 0;
--needScrollTimes;
if (currentTextIndex >= textList.size()) {// 文字循环展示
if (isScrollForever) {
currentTextIndex = 0;
} else {
stopScroll = true;
break;
}
}
measureTextParams(false);
}
} else { // 垂直滚动逻辑
drawVerticalScroll();
isSetNewText = false;
--needScrollTimes;
}
if (needScrollTimes <= 0 && isScrollForever) { // 滚动次数小于0,则滚动结束
stopScroll = true;
}
}
}
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ignored) {
}
}
}
5. 使用示例
bash
// 文字列表
val textList = mutableListOf(
"关于开展2025年市直公益性岗位征集工作的通知",
"国家发通知要求规范OTA升级 广汽将构建全域智行安全守护体系",
"2月28日,工信部联合市场监管总局发布了《关于进一步加强智能网联汽车准入、召回及软件在线升级管理的通知》(以下简称《通知》),为汽车产业健康有序发展提供了顶层指引。对此,广汽集团表示,承诺所有安全投入绝不转嫁用户成本。"
)
// 横向
mBind.scrollTextH.apply {
setTextList(textList)
this.isHorizontal = true
speed = 4
textColor = Color.WHITE
textBackColor = R.color.teal_200
isClickEnable = true
setOnTextClickListener { index, text ->
ToastUtils.show("点击了 $index $text")
}
}
// 竖向
mBind.scrollTextV.apply {
setTextList(textList) // 文字列表
this.isHorizontal = false // 是否横向滚动
speed = 4 // 滚动速度
stayTimes = 3000 // 停留时间 3s
textColor = Color.WHITE // 文字颜色
textBackColor = R.color.teal_700 // 文字背景颜色
isClickEnable = false // 是否可点击
}
6. 最后
以上功能可能只满足部分场景,如需要更多功能,比如:滚动方向,多行滚动等,可自行拓展。直接看代码,可能不容易理解,但是把功能的细节点全都剖析出来,在脑子里有个完整的轮廓,再去看代码,就事半功倍了。see you ~