前言
自定义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 WeakReferencemView; // 帧率相关 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 (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 WeakReferencemView; 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; } }
效果
总结
到此这篇关于Android TextView跑马灯实现的文章就介绍到这了,更多相关Android TextView跑马灯内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!