前言
自定义View实现的跑马灯一直没有实现类似 Android TextView
的跑马灯首尾相接的效果,所以一直想看看Android TextView
的跑马灯是如何实现
本文主要探秘 Android TextView
的跑马灯实现原理及实现自下往上效果的跑马灯
探秘
TextView#onDraw
原生 Android TextView
如何设置开启跑马灯效果,此处不再描述
View
的绘制都在 onDraw
方法中,这里直接查看 TextView#onDraw()
方法,删减一些不关心的代码
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
代码较少,就贴上全部源码吧
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
,跑马灯实现原理豁然明亮
- 在
TextView
开启跑马灯效果时调用Marquee#start()
方法 - 在
Marquee#start()
方法中触发TextView
重绘,开始计算跑动距离 - 在
TextView#onDraw()
方法中根据跑动距离移动画布并绘制首部文本,再根据跑动距离判断是否可以移动画布绘制尾部文本
小结
TextView
通过移动画布绘制两次文本实现跑马灯效果,根据两帧绘制的时间差计算跑动距离,怎一个"妙"了得
应用
上面分析完原生 Android TextView
跑马灯的实现原理,但是原生 Android TextView
跑马灯有几点不足:
- 无法设置跑动速度
- 无法设置重跑间隔时长
- 无法实现上下跑动
以上第1、2点在上面 Marquee
分析中已经有解决方案,接下来根据原生实现原理实现第3点上下跑动
MarqueeTextView
这里给出实现方案,列出主要实现逻辑,继承 AppCompatTextView
,复写 onDraw()
方法,上下跑动主要是计算上下跑动的距离,然后再次重绘 TextView
上下移动画布绘制文本
/**
* 继承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
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~