关于android:AndroidTextView跑马灯探秘

41次阅读

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

  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~

正文完
 0