共计 13859 个字符,预计需要花费 35 分钟才能阅读完成。
前言
自定义 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~
正文完