【车载Android】自定义跑马灯控件 HandlerMarqueeTextView,支持富文本颜色和3种滚动模式,附源码

🚗 写在前面

在车载中控屏(IVI)开发中,跑马灯(Marquee)是非常常见的交互组件------导航目的地、蓝牙音乐歌名、AI识别的乘客姓名+情绪标签......这些内容往往文本长度不定,一行显示不下,又需要 连续滚动 以呈现完整信息。

Android 原生 TextView 虽然自带 ellipsize="marquee" ,但它有几个致命问题:

  1. 需要 isFocused() 才能滚动 ------ 车载场景下焦点管理很混乱,很容易停止滚动
  2. 无法控制滚动速度和方向 ------ 只能匀速从右往左滚
  3. 不支持 Spannable 富文本颜色 ------ 原生跑马灯模式下 ForegroundColorSpan 经常失效
  4. 滚动条件苛刻 ------ 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 为您自己的包名即可直接使用。

有问题欢迎在评论区交流 👍🏻

相关推荐
じ星不离月か3 个月前
【记录】 跑马灯无限滚动
前端·css·跑马灯·无限滚动
一川月白7099 个月前
51单片机---硬件学习(跑马灯、数码管、外部中断、按键、蜂鸣器)
单片机·学习·51单片机·外部中断·蜂鸣器·数码管·跑马灯
zlbcdn1 年前
Arduino学习-跑马灯
arduino·跑马灯
胖虎11 年前
iOS文字滚动:使用CATextLayer实现的跑马灯(附源码)
ios·跑马灯·catextlayer·文字滚动
zhongvv3 年前
应广单片机实现跑马灯
应广单片机·应广单片机开发·跑马灯·应广demo·方案开发