0%

Android-TextView跑马灯探秘

Android-TextView跑马灯探秘

前言

自定义View实现的跑马灯一直没有实现类似 Android TextView 的跑马灯首尾相接的效果,所以一直想看看Android TextView 的跑马灯是如何实现

本文主要探秘 Android TextView 的跑马灯实现原理及实现自下往上效果的跑马灯

探秘

TextView#onDraw

原生 Android TextView 如何设置开启跑马灯效果,此处不再描述

View 的绘制都在 onDraw 方法中,这里直接查看 TextView#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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
protected void onDraw(Canvas canvas) {
// 是否需要重新启动跑马灯
restartMarqueeIfNeeded();

// Draw the background for this view
super.onDraw(canvas);

// 删减不关心的代码

// 创建`mLayout`对象, 此处为`StaticLayout`
if (mLayout == null) {
assumeLayout();
}

Layout layout = mLayout;

canvas.save();

// 删减不关心的代码

final int layoutDirection = getLayoutDirection();
final int absoluteGravity = Gravity.getAbsoluteGravity(mGravity, layoutDirection);

// 判断跑马灯设置项是否正确
if (isMarqueeFadeEnabled()) {
if (!mSingleLine && getLineCount() == 1 && canMarquee()
&& (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) != Gravity.LEFT) {
final int width = mRight - mLeft;
final int padding = getCompoundPaddingLeft() + getCompoundPaddingRight();
final float dx = mLayout.getLineRight(0) - (width - padding);
canvas.translate(layout.getParagraphDirection(0) * dx, 0.0f);
}

// 判断跑马灯是否启动
if (mMarquee != null && mMarquee.isRunning()) {
final float dx = -mMarquee.getScroll();
// 移动画布
canvas.translate(layout.getParagraphDirection(0) * dx, 0.0f);
}
}

final int cursorOffsetVertical = voffsetCursor - voffsetText;

Path highlight = getUpdatedHighlightPath();
if (mEditor != null) {
mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical);
} else {
// 绘制文本
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);
}

// 判断是否可以绘制尾部文本
if (mMarquee != null && mMarquee.shouldDrawGhost()) {
final float dx = mMarquee.getGhostOffset();
// 移动画布
canvas.translate(layout.getParagraphDirection(0) * dx, 0.0f);
// 绘制尾部文本
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);
}

canvas.restore();
}

Marquee

根据 onDraw() 方法分析,跑马灯效果的实现主要依赖 mMarquee 这个对象来实现,好的,看下 Marquee 吧,Marquee 代码较少,就贴上全部源码吧

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
private static final class Marquee {
// TODO: Add an option to configure this
// 缩放相关,不关心此字段
private static final float MARQUEE_DELTA_MAX = 0.07f;

// 跑马灯跑完一次后多久开始下一次
private static final int MARQUEE_DELAY = 1200;

// 绘制一次跑多长距离因子,此字段与速度相关
private static final int MARQUEE_DP_PER_SECOND = 30;

// 跑马灯状态常量
private static final byte MARQUEE_STOPPED = 0x0;
private static final byte MARQUEE_STARTING = 0x1;
private static final byte MARQUEE_RUNNING = 0x2;

// 对TextView进行弱引用
private final WeakReference<TextView> mView;

// 帧率相关
private final Choreographer mChoreographer;

// 状态
private byte mStatus = MARQUEE_STOPPED;

// 绘制一次跑多长距离
private final float mPixelsPerMs;

// 最大滚动距离
private float mMaxScroll;

// 是否可以绘制右阴影, 右侧淡入淡出效果
private float mMaxFadeScroll;

// 尾部文本什么时候开始绘制
private float mGhostStart;

// 尾部文本绘制位置偏移量
private float mGhostOffset;

// 是否可以绘制左阴影,左侧淡入淡出效果
private float mFadeStop;

// 重复限制
private int mRepeatLimit;

// 跑动距离
private float mScroll;

// 最后一次跑动时间,单位毫秒
private long mLastAnimationMs;

Marquee(TextView v) {
final float density = v.getContext().getResources().getDisplayMetrics().density;
// 计算每次跑多长距离
mPixelsPerMs = MARQUEE_DP_PER_SECOND * density / 1000f;
mView = new WeakReference<TextView>(v);
mChoreographer = Choreographer.getInstance();
}

// 帧率回调,用于跑马灯跑动
private Choreographer.FrameCallback mTickCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
tick();
}
};

// 帧率回调,用于跑马灯开始跑动
private Choreographer.FrameCallback mStartCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
mStatus = MARQUEE_RUNNING;
mLastAnimationMs = mChoreographer.getFrameTime();
tick();
}
};

// 帧率回调,用于跑马灯重新跑动
private Choreographer.FrameCallback mRestartCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (mStatus == MARQUEE_RUNNING) {
if (mRepeatLimit >= 0) {
mRepeatLimit--;
}
start(mRepeatLimit);
}
}
};

// 跑马灯跑动实现
void tick() {
if (mStatus != MARQUEE_RUNNING) {
return;
}

mChoreographer.removeFrameCallback(mTickCallback);

final TextView textView = mView.get();
// 判断TextView是否处于获取焦点或选中状态
if (textView != null && (textView.isFocused() || textView.isSelected())) {
// 获取当前时间
long currentMs = mChoreographer.getFrameTime();
// 计算当前时间与上次时间的差值
long deltaMs = currentMs - mLastAnimationMs;
mLastAnimationMs = currentMs;
// 根据时间差计算本次跑动的距离,减轻视觉上跳动/卡顿
float deltaPx = deltaMs * mPixelsPerMs;
// 计算跑动距离
mScroll += deltaPx;
// 判断是否已经跑完
if (mScroll > mMaxScroll) {
mScroll = mMaxScroll;
// 发送重新开始跑动事件
mChoreographer.postFrameCallbackDelayed(mRestartCallback, MARQUEE_DELAY);
} else {
// 发送下一次跑动事件
mChoreographer.postFrameCallback(mTickCallback);
}
// 调用此方法会触发执行`onDraw`方法
textView.invalidate();
}
}

// 停止跑马灯
void stop() {
mStatus = MARQUEE_STOPPED;
mChoreographer.removeFrameCallback(mStartCallback);
mChoreographer.removeFrameCallback(mRestartCallback);
mChoreographer.removeFrameCallback(mTickCallback);
resetScroll();
}

private void resetScroll() {
mScroll = 0.0f;
final TextView textView = mView.get();
if (textView != null) textView.invalidate();
}

// 启动跑马灯
void start(int repeatLimit) {
if (repeatLimit == 0) {
stop();
return;
}
mRepeatLimit = repeatLimit;
final TextView textView = mView.get();
if (textView != null && textView.mLayout != null) {
// 设置状态为在跑
mStatus = MARQUEE_STARTING;
// 重置跑动距离
mScroll = 0.0f;
// 计算TextView宽度
final int textWidth = textView.getWidth() - textView.getCompoundPaddingLeft()
- textView.getCompoundPaddingRight();
// 获取文本第0行的宽度
final float lineWidth = textView.mLayout.getLineWidth(0);
// 取TextView宽度的三分之一
final float gap = textWidth / 3.0f;
// 计算什么时候可以开始绘制尾部文本:首部文本跑动到哪里可以绘制尾部文本
mGhostStart = lineWidth - textWidth + gap;
// 计算最大滚动距离:什么时候认为跑完一次
mMaxScroll = mGhostStart + textWidth;
// 尾部文本绘制偏移量
mGhostOffset = lineWidth + gap;
// 跑动到哪里时不绘制左侧阴影
mFadeStop = lineWidth + textWidth / 6.0f;
// 跑动到哪里时不绘制右侧阴影
mMaxFadeScroll = mGhostStart + lineWidth + lineWidth;

textView.invalidate();
// 开始跑动
mChoreographer.postFrameCallback(mStartCallback);
}
}

// 获取尾部文本绘制位置偏移量
float getGhostOffset() {
return mGhostOffset;
}

// 获取当前滚动距离
float getScroll() {
return mScroll;
}

// 获取可以右侧阴影绘制的最大距离
float getMaxFadeScroll() {
return mMaxFadeScroll;
}

// 判断是否可以绘制左侧阴影
boolean shouldDrawLeftFade() {
return mScroll <= mFadeStop;
}

// 判断是否可以绘制尾部文本
boolean shouldDrawGhost() {
return mStatus == MARQUEE_RUNNING && mScroll > mGhostStart;
}

// 跑马灯是否在跑
boolean isRunning() {
return mStatus == MARQUEE_RUNNING;
}

// 跑马灯是否不跑
boolean isStopped() {
return mStatus == MARQUEE_STOPPED;
}
}

好的,分析完 Marquee,跑马灯实现原理豁然明亮

  1. TextView 开启跑马灯效果时调用 Marquee#start() 方法
  2. Marquee#start() 方法中触发 TextView 重绘,开始计算跑动距离
  3. TextView#onDraw() 方法中根据跑动距离移动画布并绘制首部文本,再根据跑动距离判断是否可以移动画布绘制尾部文本

小结

TextView 通过移动画布绘制两次文本实现跑马灯效果,根据两帧绘制的时间差计算跑动距离,怎一个"妙"了得

应用

上面分析完原生 Android TextView 跑马灯的实现原理,但是原生 Android TextView 跑马灯有几点不足:

  1. 无法设置跑动速度
  2. 无法设置重跑间隔时长
  3. 无法实现上下跑动

以上第1、2点在上面 Marquee 分析中已经有解决方案,接下来根据原生实现原理实现第3点上下跑动

MarqueeTextView

这里给出实现方案,列出主要实现逻辑,继承 AppCompatTextView,复写 onDraw() 方法,上下跑动主要是计算上下跑动的距离,然后再次重绘 TextView 上下移动画布绘制文本

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/**
* 继承AppCompatTextView,复写onDraw方法
*/
public class MarqueeTextView extends AppCompatTextView {

private static final int DEFAULT_BG_COLOR = Color.parseColor("#FFEFEFEF");

@IntDef({HORIZONTAL, VERTICAL})
@Retention(RetentionPolicy.SOURCE)
public @interface OrientationMode {
}

public static final int HORIZONTAL = 0;
public static final int VERTICAL = 1;

private Marquee mMarquee;
private boolean mRestartMarquee;
private boolean isMarquee;

private int mOrientation;

public MarqueeTextView(@NonNull Context context) {
this(context, null);
}

public MarqueeTextView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public MarqueeTextView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MarqueeTextView, defStyleAttr, 0);

mOrientation = ta.getInt(R.styleable.MarqueeTextView_orientation, HORIZONTAL);

ta.recycle();
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);

if (mOrientation == HORIZONTAL) {
if (getWidth() > 0) {
mRestartMarquee = true;
}
} else {
if (getHeight() > 0) {
mRestartMarquee = true;
}
}
}

private void restartMarqueeIfNeeded() {
if (mRestartMarquee) {
mRestartMarquee = false;
startMarquee();
}
}

public void setMarquee(boolean marquee) {
boolean wasStart = isMarquee();

isMarquee = marquee;

if (wasStart != marquee) {
if (marquee) {
startMarquee();
} else {
stopMarquee();
}
}
}

public void setOrientation(@OrientationMode int orientation) {
mOrientation = orientation;
}

public int getOrientation() {
return mOrientation;
}

public boolean isMarquee() {
return isMarquee;
}

private void stopMarquee() {
if (mOrientation == HORIZONTAL) {
setHorizontalFadingEdgeEnabled(false);
} else {
setVerticalFadingEdgeEnabled(false);
}

requestLayout();
invalidate();

if (mMarquee != null && !mMarquee.isStopped()) {
mMarquee.stop();
}
}

private void startMarquee() {
if (canMarquee()) {

if (mOrientation == HORIZONTAL) {
setHorizontalFadingEdgeEnabled(true);
} else {
setVerticalFadingEdgeEnabled(true);
}

if (mMarquee == null) mMarquee = new Marquee(this);
mMarquee.start(-1);
}
}

private boolean canMarquee() {
if (mOrientation == HORIZONTAL) {
int viewWidth = getWidth() - getCompoundPaddingLeft() -
getCompoundPaddingRight();
float lineWidth = getLayout().getLineWidth(0);
return (mMarquee == null || mMarquee.isStopped())
&& (isFocused() || isSelected() || isMarquee())
&& viewWidth > 0
&& lineWidth > viewWidth;
} else {
int viewHeight = getHeight() - getCompoundPaddingTop() -
getCompoundPaddingBottom();
float textHeight = getLayout().getHeight();
return (mMarquee == null || mMarquee.isStopped())
&& (isFocused() || isSelected() || isMarquee())
&& viewHeight > 0
&& textHeight > viewHeight;
}
}

/**
* 仿照TextView#onDraw()方法
*/
@Override
protected void onDraw(Canvas canvas) {
restartMarqueeIfNeeded();

super.onDraw(canvas);

// 再次绘制背景色,覆盖下面由TextView绘制的文本,视情况可以不调用`super.onDraw(canvas);`
// 如果没有背景色则使用默认颜色
Drawable background = getBackground();
if (background != null) {
background.draw(canvas);
} else {
canvas.drawColor(DEFAULT_BG_COLOR);
}

canvas.save();

canvas.translate(0, 0);

// 实现左右跑马灯
if (mOrientation == HORIZONTAL) {
if (mMarquee != null && mMarquee.isRunning()) {
final float dx = -mMarquee.getScroll();
canvas.translate(dx, 0.0F);
}

getLayout().draw(canvas, null, null, 0);

if (mMarquee != null && mMarquee.shouldDrawGhost()) {
final float dx = mMarquee.getGhostOffset();
canvas.translate(dx, 0.0F);
getLayout().draw(canvas, null, null, 0);
}
} else {
// 实现上下跑马灯
if (mMarquee != null && mMarquee.isRunning()) {
final float dy = -mMarquee.getScroll();
canvas.translate(0.0F, dy);
}

getLayout().draw(canvas, null, null, 0);

if (mMarquee != null && mMarquee.shouldDrawGhost()) {
final float dy = mMarquee.getGhostOffset();
canvas.translate(0.0F, dy);
getLayout().draw(canvas, null, null, 0);
}
}

canvas.restore();
}
}

Marquee

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
private static final class Marquee {
// 修改此字段设置重跑时间间隔 - 对应不足点2
private static final int MARQUEE_DELAY = 1200;

// 修改此字段设置跑动速度 - 对应不足点1
private static final int MARQUEE_DP_PER_SECOND = 30;

private static final byte MARQUEE_STOPPED = 0x0;
private static final byte MARQUEE_STARTING = 0x1;
private static final byte MARQUEE_RUNNING = 0x2;

private static final String METHOD_GET_FRAME_TIME = "getFrameTime";

private final WeakReference<MarqueeTextView> mView;
private final Choreographer mChoreographer;

private byte mStatus = MARQUEE_STOPPED;
private final float mPixelsPerSecond;
private float mMaxScroll;
private float mMaxFadeScroll;
private float mGhostStart;
private float mGhostOffset;
private float mFadeStop;
private int mRepeatLimit;

private float mScroll;
private long mLastAnimationMs;

Marquee(MarqueeTextView v) {
final float density = v.getContext().getResources().getDisplayMetrics().density;
mPixelsPerSecond = MARQUEE_DP_PER_SECOND * density;
mView = new WeakReference<>(v);
mChoreographer = Choreographer.getInstance();
}

private final Choreographer.FrameCallback mTickCallback = frameTimeNanos -> tick();

private final Choreographer.FrameCallback mStartCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
mStatus = MARQUEE_RUNNING;
mLastAnimationMs = getFrameTime();
tick();
}
};

/**
* `getFrameTime`是隐藏api,此处使用反射调用,高系统版本可能失效,可使用某些方案绕过此限制
*/
@SuppressLint("PrivateApi")
private long getFrameTime() {
try {
Class<? extends Choreographer> clz = mChoreographer.getClass();
Method getFrameTime = clz.getDeclaredMethod(METHOD_GET_FRAME_TIME);
getFrameTime.setAccessible(true);
return (long) getFrameTime.invoke(mChoreographer);
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}

private final Choreographer.FrameCallback mRestartCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (mStatus == MARQUEE_RUNNING) {
if (mRepeatLimit >= 0) {
mRepeatLimit--;
}
start(mRepeatLimit);
}
}
};

void tick() {
if (mStatus != MARQUEE_RUNNING) {
return;
}

mChoreographer.removeFrameCallback(mTickCallback);

final MarqueeTextView textView = mView.get();
if (textView != null && (textView.isFocused() || textView.isSelected() || textView.isMarquee())) {
long currentMs = getFrameTime();
long deltaMs = currentMs - mLastAnimationMs;
mLastAnimationMs = currentMs;
float deltaPx = deltaMs / 1000F * mPixelsPerSecond;
mScroll += deltaPx;
if (mScroll > mMaxScroll) {
mScroll = mMaxScroll;
mChoreographer.postFrameCallbackDelayed(mRestartCallback, MARQUEE_DELAY);
} else {
mChoreographer.postFrameCallback(mTickCallback);
}
textView.invalidate();
}
}

void stop() {
mStatus = MARQUEE_STOPPED;
mChoreographer.removeFrameCallback(mStartCallback);
mChoreographer.removeFrameCallback(mRestartCallback);
mChoreographer.removeFrameCallback(mTickCallback);
resetScroll();
}

private void resetScroll() {
mScroll = 0.0F;
final MarqueeTextView textView = mView.get();
if (textView != null) textView.invalidate();
}

void start(int repeatLimit) {
if (repeatLimit == 0) {
stop();
return;
}
mRepeatLimit = repeatLimit;
final MarqueeTextView textView = mView.get();
if (textView != null && textView.getLayout() != null) {
mStatus = MARQUEE_STARTING;
mScroll = 0.0F;

// 分别计算左右和上下跑动所需的数据
if (textView.getOrientation() == HORIZONTAL) {
int viewWidth = textView.getWidth() - textView.getCompoundPaddingLeft() -
textView.getCompoundPaddingRight();
float lineWidth = textView.getLayout().getLineWidth(0);
float gap = viewWidth / 3.0F;
mGhostStart = lineWidth - viewWidth + gap;
mMaxScroll = mGhostStart + viewWidth;
mGhostOffset = lineWidth + gap;
mFadeStop = lineWidth + viewWidth / 6.0F;
mMaxFadeScroll = mGhostStart + lineWidth + lineWidth;
} else {
int viewHeight = textView.getHeight() - textView.getCompoundPaddingTop() -
textView.getCompoundPaddingBottom();
float textHeight = textView.getLayout().getHeight();
float gap = viewHeight / 3.0F;
mGhostStart = textHeight - viewHeight + gap;
mMaxScroll = mGhostStart + viewHeight;
mGhostOffset = textHeight + gap;
mFadeStop = textHeight + viewHeight / 6.0F;
mMaxFadeScroll = mGhostStart + textHeight + textHeight;
}

textView.invalidate();
mChoreographer.postFrameCallback(mStartCallback);
}
}

float getGhostOffset() {
return mGhostOffset;
}

float getScroll() {
return mScroll;
}

float getMaxFadeScroll() {
return mMaxFadeScroll;
}

boolean shouldDrawLeftFade() {
return mScroll <= mFadeStop;
}

boolean shouldDrawTopFade() {
return mScroll <= mFadeStop;
}

boolean shouldDrawGhost() {
return mStatus == MARQUEE_RUNNING && mScroll > mGhostStart;
}

boolean isRunning() {
return mStatus == MARQUEE_RUNNING;
}

boolean isStopped() {
return mStatus == MARQUEE_STOPPED;
}
}

效果

跑马灯

happy~

欢迎关注我的其它发布渠道