走马灯式横向滚动的TextView

1,088 阅读3分钟

简介

我们可以设置TextViewandroid:ellipsize="marquee"属性,来做到当文字超出一行的时候呈现跑马灯效果。但TextView的这个走马灯效果需要获取焦点,而同一时间只有一个控件可以获得焦点,更重要的是产品要求无论文字内容是否超出一行,都要滚动效果。

这里先贴一下最后实现的Github地址和效果图

github.com/dreamgyf/Ma…

MarqueeTextView

思路

思路其实很简单,我们只要将单行的TextView截成一张Bitmap,然后我们再自定义一个View,重写它的onDraw方法,每隔一段时间,将这张Bitmap画在不同的坐标上(左右两边各draw一次),这样连续起来看起来就是走马灯效果了。

后来和同事讨论,他提出能不能通过Canvas的平移配合drawText实现这个功能,我想应该也是可以的,但我没有做尝试,各位看官感兴趣的可是试一下这种方案。

实现

我们先自定义一个View继承自AppCompatTextView,再在初始化的时候new一个TextView,并重写onMeasureonLayout方法

private void init() {
    mTextView = new TextView(getContext(), attrs);
    //TextView如果没有设置LayoutParams,当setText的时候会引发NPE导致崩溃
    mTextView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
    mTextView.setMaxLines(1);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    //宽度不设限制
    mTextView.measure(MeasureSpec.UNSPECIFIED, heightMeasureSpec);
}

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    //保证布局包含完整的Text内容
    mTextView.layout(left, top, left + mTextView.getMeasuredWidth(), bottom);
}

这样做是为了利用这个内部TextView生成我们需要的Bitmap,同时借用TextView写好的onMeasure方法,这样我们就不用再那么复杂的重写onMeasure方法了

接下来是生成Bitmap

private void updateBitmap() {
    mBitmap = Bitmap.createBitmap(mTextView.getMeasuredWidth(), getMeasuredHeight(), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(mBitmap);
    mTextView.draw(canvas);
}

这个很简单,需要注意的是长度要使用内部持有的TextViewgetMeasuredWidth,如果使用getWidth的话,最大值为屏幕的宽度,很可能导致生成出的Bitmap不全,高度用谁的倒是无所谓

在每次setTextsetTextSize的时候都需要更新Bitmap并重新布局绘制

private void init() {
    mTextView.addOnLayoutChangeListener(new OnLayoutChangeListener() {
        @Override
        public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
            updateBitmap();
            restartScroll();
        }
    });
}

@Override
public void setText(CharSequence text, BufferType type) {
    super.setText(text, type);
    //执行父类构造函数时,如果AttributeSet中有text参数会先调用setText,此时mTextView尚未初始化
    if (mTextView != null) {
        mTextView.setText(text);
        requestLayout();
    }
}

@Override
public void setTextSize(int unit, float size) {
    super.setTextSize(unit, size);
    //执行父类构造函数时,如果AttributeSet中有textSize参数会先调用setTextSize,此时mTextView尚未初始化
    if (mTextView != null) {
        mTextView.setTextSize(size);
        requestLayout();
    }
}

接下来,我给这个MarqueeTextView定义了一些参数,一个是space(文字滚动时,头尾的最小间隔距离),另一个是speed(文字滚动的速度)

先看一下onDraw的实现吧

@Override
protected void onDraw(Canvas canvas) {
    if (mBitmap != null) {
        //当文字内容不超过一行
        if (mTextView.getMeasuredWidth() <= getWidth()) {
            //计算头尾需要间隔的宽度
            int space = mSpace - (getWidth() - mTextView.getMeasuredWidth());
            if (space < 0) {
                space = 0;
            }

            //当左边的drawBitmap的坐标超过了显示宽度+间隔宽度,即走完一个循环,右边的Bitmap已经挪到了最左边,将坐标重置
            if (mLeftX < -getWidth() - space) {
                mLeftX += getWidth() + space;
            }

            //画左边的bitmap
            canvas.drawBitmap(mBitmap, mLeftX, 0, getPaint());
            if (mLeftX < 0) {
                //画右边的bitmap,位置为最右边的坐标-左边bitmap已消失的宽度+间隔宽度
                canvas.drawBitmap(mBitmap, getWidth() + mLeftX + space, 0, getPaint());
            }
        } else {
            //当文字内容超过一行
            //当左边的drawBitmap的坐标超过了内容宽度+间隔宽度,即走完一个循环,右边的Bitmap已经挪到了最左边,将坐标重置
            if (mLeftX < -mTextView.getMeasuredWidth() - mSpace) {
                mLeftX += mTextView.getMeasuredWidth() + mSpace;
            }

            //画左边的bitmap
            canvas.drawBitmap(mBitmap, mLeftX, 0, getPaint());
            //当尾部已经显示出来的时候
            if (mLeftX + (mTextView.getMeasuredWidth() - getWidth()) < 0) {
                //画右边的bitmap,位置为尾部的坐标+间隔宽度
                canvas.drawBitmap(mBitmap, mTextView.getMeasuredWidth() + mLeftX + mSpace, 0, getPaint());
            }
        }
    }
}

这就是基本的绘制思路

接下来需要让他动起来,这里使用的Choreographer,每次收到Vsync信号系统绘制新帧时都更新一下坐标并重绘

private static final float BASE_FPS = 60f;

private float mFps = BASE_FPS;

/**
 * 获取当前屏幕刷新率
 */
private void updateFps() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        mFps = context.getDisplay().getRefreshRate();
    } else {
        WindowManager windowManager =
                (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        mFps = windowManager.getDefaultDisplay().getRefreshRate();
    }
}

private Choreographer.FrameCallback frameCallback = new Choreographer.FrameCallback() {
    @Override
    public void doFrame(long frameTimeNanos) {
        invalidate();
        //保证在不同刷新率的屏幕上,视觉上的速度一致
        int speed = (int) (BASE_FPS / mFps * mSpeed);
        mLeftX -= speed;
        Choreographer.getInstance().postFrameCallback(this);
    }
};

public void startScroll() {
    Choreographer.getInstance().postFrameCallback(frameCallback);
}

public void pauseScroll() {
    Choreographer.getInstance().removeFrameCallback(frameCallback);
}

public void stopScroll() {
    mLeftX = 0;
    Choreographer.getInstance().removeFrameCallback(frameCallback);
}

public void restartScroll() {
    stopScroll();
    startScroll();
}

最后,在View可见性发生变化时,需要控制一下动画的启停

@Override
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
    if (visibility == VISIBLE) {
        updateFps();
        Choreographer.getInstance().postFrameCallback(frameCallback);
    } else {
        Choreographer.getInstance().removeFrameCallback(frameCallback);
    }
}