目录
  • 前言
  • 探秘
    • TextView#onDraw
    • Marquee
    • 小结
  • 应用
    • MarqueeTextView
    • Marquee
    • 效果
  • 总结

    前言

    自定义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;
         }
     }

    效果

    Android TextView跑马灯实现原理及方法实例

    总结

    声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。