重学 Android 自定义 View 系列(十一):文字跑马灯剖析

前言

一个可以横向滚动和纵向滚动的自定义文字跑马灯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. 阶段1
  • 文本从视图底部之外滚动到基线位置。

  • 如果文本超长,准备触发水平滚动。

  1. 阶段2
  • (若文本过长)文本从右向左水平滚动,直到文本末尾完全显示。

  • 水平滚动完成后,暂停 stayTimes 毫秒。

  1. 阶段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 ~

相关推荐
_一条咸鱼_2 分钟前
Android Glide 的显示与回调模块原理分析
android
_一条咸鱼_6 分钟前
Android Glide 图片解码与转换模块原理分析
android
QING6186 分钟前
Android_BLE开发——扫描
android·kotlin·app
QING6187 分钟前
Android_BLE开发——绑定
android·kotlin·app
顾林海8 分钟前
深入理解 Dart 函数:从基础到高阶应用
android·前端·flutter
QING6188 分钟前
Android_BLE开发——连接
android·kotlin·app
QING6189 分钟前
Android_BLE开发——优化(深入解决 STATUS=133 连接错误)
android·kotlin·app
QING6189 分钟前
Android_BLE开发——读写
android·kotlin·app
_一条咸鱼_41 分钟前
Android Glide 缓存模块源码深度解析
android
harry235day1 小时前
kotlin 协程创建启动 源码初探(一)
android·kotlin