前言

自定义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,跑马灯实现原理释然亮堂

  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 高低挪动画布绘制文本

/** * 继承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~