走马灯式横向滚动的TextView

简介

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

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

https://github.com/dreamgyf/MarqueeTextView

MarqueeTextView

思路

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

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

实现

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

1
2
3
4
5
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并重新布局绘制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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的实现吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@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信号系统绘制新帧时都更新一下坐标并重绘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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可见性发生变化时,需要控制一下动画的启停

1
2
3
4
5
6
7
8
9
@Override
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
if (visibility == VISIBLE) {
updateFps();
Choreographer.getInstance().postFrameCallback(frameCallback);
} else {
Choreographer.getInstance().removeFrameCallback(frameCallback);
}
}