浅谈TextView Ellipsize效果与Marquee跑马灯无效果问题

说到TextView 效果,相信大家一定熟悉跑马灯。

先来看看 Ellipsize是什么,Ellipsize 从开发技术上翻译为省略效果。故名思议,就是当文本无法显示全部时,用什么效果来显示未显示的部分。

一,What is Ellipsize  and  How to use ?

首先我们在Android XML中需要这样定义

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="30dp"
        android:text="@string/hello_world"  //全部文字为:Hello world!这是一个跑马灯专门用来测试的,所以字数一定要多,不过肯定超过15字了。
        
        android:ellipsize="end"
        android:singleLine="true"
        
        
        android:textSize="20sp"
        />

 显示效果:

可以看到在这段文字后面有三个"..."来表示这段话没有显示完毕,后面还有一部分。所以问题来了这个效果怎么来的呢?

 android:ellipsize="end"
 android:singleLine="true"

 起作用的就是这两行代码。

android:singleLine="true"用来指定当前TextView为单行显示,意思就是无论字数多少,都要一行来显示,这个也是显示Ellipsize效果的必须条件。

至于为什么需要android:singleLine="true"后面讲解源码会说到。我们再来看android:ellipsize这个属性。

它是由TextView中的一个枚举定义的,我们看看这个数据结构:

  public enum TruncateAt {
        START,
        MIDDLE,
        END,
        MARQUEE,
        /**
         * @hide
         */
        END_SMALL
    }

 从枚举名TruncateAt的意思:在什么地方截断。顾名思义就是在TextView的内容中哪个位置显示截断效果。下面几个图分别代表每个变量的效果:

TruncateAt.MARQUEE就是跑马灯效果。但是本该是一行文字从坐往右的滚动,但是实际上只添加

android:ellipsize="marquee"
android:singleLine="true"

这两个属性,看到就是上图那个样子。仔细一点你会发现那个其实不是普通的效果:"来"字后半部有渐进透明,后面会讲解跑马灯效果不显示的原因。

其实在实际Android XML中还有一个属性android:ellipsize="none",这个意思是不指定省略效果:

我们可以看到这段话多出了"测试"两个字,但是"试"被截断了。其实android:ellipsize的默认值为end。因为在TextView的构造函数中有这样一行代码:

   if (singleLine && getKeyListener() == null && ellipsize < 0) {
                ellipsize = 3; // END
        }

意思是即使你只设定了 android:singleLine="true"。那么也会显示android:ellipsize="end"时的效果。

 二,为什么跑马灯无效果?

对于这个问题,相比大家都知道,当我们写了如下代码时:

  <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="30dp"
        android:text="@string/hello_world"
        android:ellipsize="marquee"
        android:singleLine="true"
        android:textSize="20sp"
        />

却发现应用运行时跑马灯效果却没有。妈蛋,这是为毛。这个时候相比大家都能百度到解法。下面有两个网上最流行的解法

1.重写TextView

2.需要调用TextViews一大堆的函数。

起初我看到这个非常不爽,还能不能好好的玩耍了,妈蛋,android:ellipsize="start|middle|end"都能一行代码显示,为毛这个不行。想要知道问题原因很简单。

知道了why,你就知道了how。

谈到跑马灯,里面还是有点复杂的的。Android SDK将TextView 的跑马灯封装起来了。

TextView继承至View。所以它中元素的绘制都在onDraw()中。在TextView中有段代码:

 final int layoutDirection = getLayoutDirection();
        final int absoluteGravity = Gravity.getAbsoluteGravity(mGravity, layoutDirection);
        if (mEllipsize == TextUtils.TruncateAt.MARQUEE &&
                mMarqueeFadeMode != MARQUEE_FADE_SWITCH_SHOW_ELLIPSIS) {
            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 float dx = -mMarquee.getScroll();就是TextView的位移量,而canvas.translate(layout.getParagraphDirection(0) * dx, 0.0f);这行代码通过位移画布

来做到跑马灯效果。但是这个mMarquee.getScroll()到底是什么呢?

查阅源码便可发现TextView 中有一个内部类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_RESTART_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;

        private final WeakReference<TextView> 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(TextView v) {
            final float density = v.getContext().getResources().getDisplayMetrics().density;
            mPixelsPerSecond = MARQUEE_DP_PER_SECOND * density;
            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();
            if (textView != null && (textView.isFocused() || textView.isSelected())) {
                long currentMs = mChoreographer.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 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;
                final int textWidth = textView.getWidth() - textView.getCompoundPaddingLeft() -
                        textView.getCompoundPaddingRight();
                final float lineWidth = textView.mLayout.getLineWidth(0);
                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;
        }
    }

其中getScroll值返回的是 mScroll。所以我们要找到这个值在哪被改变的。所以我们又很容易找mScroll现在start()中进行初始化:但是未正向或者负向改变mScroll的值,只是进行其他变量值的赋值。

但是在start()函数最后面有行代码:mChoreographer.postFrameCallback(mStartCallback);mChoreographer又是什么?如果再扯上Choreographer,那就说很多了。

 下面引用一篇文章的介绍:http://www.360doc.com/content/14/0827/10/10366845_405038717.shtml

所有的图像显示输出都是由时钟驱动的,这个驱动信号称为VSYNC。这个名词来源于模拟电视时代,在那个年代,因为带宽的限制,每一帧图像都有分成两次传输,先扫描偶数行(也称偶场)传输,再回到头部扫描奇数行(奇场),扫描之前,发送一个VSYNC同步信号,用于标识这个这是一场的开始。场频,也就是VSYNC 频率决定了帧率(场频/2). 在现在的数字传输中,已经没有了场的概念,但VSYNC这一概念得于保持下来,代表了图像的刷新频率,意味着收到VSYNC信号后,我们必须将新的一帧进行显示。

VSYNC一般由硬件产生,也可以由软件产生(如果够准确的话),Android 中VSYNC来着于HWComposer,接收者没错,就是Choreographer。Choreographer英文意思是编舞者,跳舞很讲究节奏不是吗,必须要踩准点。Choreographer 就是用来帮助Android的动画,输入,还是显示刷新按照固定节奏来完成工作的。

 即Marquee使用Choreographer来进行每一桢的绘制。mChoreographer.postFrameCallback(mStartCallback)有个回调:

        private Choreographer.FrameCallback mStartCallback = new Choreographer.FrameCallback() {
            @Override
            public void doFrame(long frameTimeNanos) {
                mStatus = MARQUEE_RUNNING;
                mLastAnimationMs = mChoreographer.getFrameTime();
                tick();
            }
        };

我们看到mStatus值 由start()中设定的MARQUEE_STARTING改为:MARQUEE_RUNNING。说明这个时候开始执行这个动作。其调用了tick()函数:

 void tick() {
            if (mStatus != MARQUEE_RUNNING) {
                return;
            }

            mChoreographer.removeFrameCallback(mTickCallback);

            final TextView textView = mView.get();
            if (textView != null && (textView.isFocused() || textView.isSelected())) {
                long currentMs = mChoreographer.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();
            }
        }

定位到这个函数,我们一下子明了。这个方法通过mChoreographer.getFrameTime()得到当前帧时间,然后和上一次的时间帧做减法。得到偏移量。然后进行转换,得到跑马灯应该

位置的偏移量mScroll。但是mScroll的改变也是有前提的:textView != null && (textView.isFocused() || textView.isSelected()

textView.isFocused()与textView.isSelected()使用的是短路或(||)。就说明两个方法的返回值有一定的独立性。这两个方法均为View中的方法,TextView均未重写,

再看:

textView.isFocused():

  /**
     * Returns true if this view has focus
     *
     * @return True if this view has focus, false otherwise.
     */
    @ViewDebug.ExportedProperty(category = "focus")
    public boolean isFocused() {
        return (mPrivateFlags & PFLAG_FOCUSED) != 0;
    }

源码中:

static final int PFLAG_FOCUSED = 0x00000002;
PFLAG_FOCUSED是个常量,不能被改变,而能改变的只有mPrivateFlags了。mPrivateFlags在TextView中是个变量,所以只要mPrivateFlags不等于0.则
isFocused()便返回true。

查看isSelected()方法源码:

  /**
     * Indicates the selection state of this view.
     *
     * @return true if the view is selected, false otherwise
     */
    @ViewDebug.ExportedProperty
    public boolean isSelected() {
        return (mPrivateFlags & PFLAG_SELECTED) != 0;
    }
    static final int PFLAG_SELECTED                    = 0x00000004;
PFLAG_SELECTED也是个常量,所以这个返回值也取决于mPrivateFlags。妈蛋,越扯越大,这下子扯到View层去了。想想TextView应该有某个方法能够启动这个效果。所以折回去看看哪个地方调用了mQuereen.start()方法了。
经过查看源码,start()方法被封装了:
 private void startMarquee() {
        // Do not ellipsize EditText
        if (getKeyListener() != null) return;

        if (compressText(getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight())) {
            return;
        }

        if ((mMarquee == null || mMarquee.isStopped()) && (isFocused() || isSelected()) &&
                getLineCount() == 1 && canMarquee()) {

            if (mMarqueeFadeMode == MARQUEE_FADE_SWITCH_SHOW_ELLIPSIS) {
                mMarqueeFadeMode = MARQUEE_FADE_SWITCH_SHOW_FADE;
                final Layout tmp = mLayout;
                mLayout = mSavedMarqueeModeLayout;
                mSavedMarqueeModeLayout = tmp;
                setHorizontalFadingEdgeEnabled(true);
                requestLayout();
                invalidate();
            }

            if (mMarquee == null) mMarquee = new Marquee(this);
            mMarquee.start(mMarqueeRepeatLimit);
        }
    }

但是调用mMarquee.start(mMarqueeRepeatLimit)也是有条件的,它们是用短路与(&&)进行运算:

1. mMarquee == null || mMarquee.isStopped()  : 这个不用解释了。

2. isFocused() || isSelected() :是否获得焦点或者被选中

3. getLineCount() == 1 :文本内容是否只有一行

4. canMarquee() :是否满足滚动的条件。

下面重点分析这个条件是什么,我们看下canMarquee()的源码:

private boolean canMarquee() {
        int width = (mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight());
        return width > 0 && (mLayout.getLineWidth(0) > width ||
                (mMarqueeFadeMode != MARQUEE_FADE_NORMAL && mSavedMarqueeModeLayout != null &&
                        mSavedMarqueeModeLayout.getLineWidth(0) > width));
    }

为了便于理解里面的某些数值,我画了一个简单的图:

其中:mLayout.getLineWidth(0)这个方法返回的是文本内容的宽度。

当我们在TextView指定:

android:padding="8dp"
android:drawablePadding="8dp" 

padding属性时。getCompoundPaddingLeft()和getCompoundRight()的值就会大于0

上面的图是TextView中的内容未填满的状态,这个时候width > 0,mLayout.getLineWidth(0) < width。canMarquee() 返回false。因为这个时候TextView已经显示完毕,所以是没有跑马灯效果的。

再看下面一个图:

文本的宽度明显大于with,即:mLayout.getLineWidth(0) > width成立。所以canMarqueen()。返回true。这个时候边满足跑马灯的条件。

但是这个条件这个限制应该是Android团队从交互体验上来考虑的,Ellipsize效果设计的初衷就是用来弥补字数太多而且需要一行显示的问题,如果字数刚好,即使是一行,也不需要这个效果。

回到另一个问题上,这个startMarquee()又是在哪调用的呢?经过分析,我绘制了下面的图例:

上面的方法中只有一个是提供给我们调用的:setSelect()。查看其源码:

    @Override
    public void setSelected(boolean selected) {
        boolean wasSelected = isSelected();

        super.setSelected(selected);

        if (selected != wasSelected && mEllipsize == TextUtils.TruncateAt.MARQUEE) {
            if (selected) {
                startMarquee();
            } else {
                stopMarquee();
            }
        }
    }

这尼玛不是赤裸裸的在开启这个效果嘛。shit!.

到此,应该就应该结束了。只需要设置setSelected(true)。便可让跑马灯跑起来。

但是!!突发奇想,在android XML是不是也可以使用这个属性呢。所以又找到另一个让其跑其他的方法:

android:textIsSelectable="true"

这个属性用来设定当前的TextView是否可以被选中的。用途就是当你看到一段文字时,你可以长按它,然后会弹出一个对话框,你是想复制它呢,还是剪切它呢还是怎么地。

看它所对应的setTextIsSelectabe()的源码:

    public void setTextIsSelectable(boolean selectable) {
        if (!selectable && mEditor == null) return; // false is default value with no edit data

        createEditorIfNeeded();
        if (mEditor.mTextIsSelectable == selectable) return;

        mEditor.mTextIsSelectable = selectable;
        setFocusableInTouchMode(selectable);
        setFocusable(selectable);
        setClickable(selectable);
        setLongClickable(selectable);

        // mInputType should already be EditorInfo.TYPE_NULL and mInput should be null

        setMovementMethod(selectable ? ArrowKeyMovementMethod.getInstance() : null);
        setText(mText, selectable ? BufferType.SPANNABLE : BufferType.NORMAL);

        // Called by setText above, but safer in case of future code changes
        mEditor.prepareCursorControllers();
    }

这个方法里只有上面加粗的那行  setFocusableInTouchMode(selectable);是起到触发跑马灯走起的效果。

再看其源码:

  /**
     * Set whether this view can receive focus while in touch mode.
     *
     * Setting this to true will also ensure that this view is focusable.
     *
     * @param focusableInTouchMode If true, this view can receive the focus while
     *   in touch mode.
     *
     * @see #setFocusable(boolean)
     * @attr ref android.R.styleable#View_focusableInTouchMode
     */
    public void setFocusableInTouchMode(boolean focusableInTouchMode) {
        // Focusable in touch mode should always be set before the focusable flag
        // otherwise, setting the focusable flag will trigger a focusableViewAvailable()
        // which, in touch mode, will not successfully request focus on this view
        // because the focusable in touch mode flag is not set
        setFlags(focusableInTouchMode ? FOCUSABLE_IN_TOUCH_MODE : 0, FOCUSABLE_IN_TOUCH_MODE);
        if (focusableInTouchMode) {
            setFlags(FOCUSABLE, FOCUSABLE_MASK);
        }
    }

这个方法是View中定义的,TextView并没有重写。它设定当这个View在接收到用户触摸时是否能接收到焦点。

其调用了setFlags()方法,setFlags()源码有很多,我们再看其中几段源码:

 final int newVisibility = flags & VISIBILITY_MASK;
        if (newVisibility == VISIBLE) {
            if ((changed & VISIBILITY_MASK) != 0) {
                /*
                 * If this view is becoming visible, invalidate it in case it changed while
                 * it was not visible. Marking it drawn ensures that the invalidation will
                 * go through.
                 */
                mPrivateFlags |= PFLAG_DRAWN;
                invalidate(true);

                needGlobalAttributesUpdate(true);

                // a view becoming visible is worth notifying the parent
                // about in case nothing has focus.  even if this specific view
                // isn't focusable, it may contain something that is, so let
                // the root view try to give this focus if nothing else does.
                if ((mParent != null) && (mBottom > mTop) && (mRight > mLeft)) {
                    mParent.focusableViewAvailable(this);
                }
            }
        }

 其中有行:

 /*
                 * If this view is becoming visible, invalidate it in case it changed while
                 * it was not visible. Marking it drawn ensures that the invalidation will
                 * go through.
                 */
                mPrivateFlags |= PFLAG_DRAWN;

意思是说如果这个View是可见的,那我们就应该在它的可视状态改变时去刷新它。PFLAG_DRAWN是个常量:

    static final int PFLAG_DRAWN                       = 0x00000020;

所以 :

mPrivateFlags = mPrivateFlags |  PFLAG_DRAWN;

 mPrivateFlags的值必定不等于0.还记得我们上面追踪到的isFocused()和isSelected()方法吗,要让其返回truem则PrivateFlags的值一定不能为0。

所以这个方法能达到让跑马灯有效果。

当然这只是一中投机取巧,因为在xml设定:android:focusableInTouchMode="true",这个和上面的那个方法是一样的功能,但是却依然没效果。

我们还是使用setSelect(true)即可。

总结:

  • 跑马灯效果的限制条件:
  1.  是否能获得焦点或者是否被选中
  2.  文本内容宽度是否大于TextView整个的宽度(是否能显示完)

 

  •   要想让TextView的跑马灯有效果,则有下面几种方法:

       1.xml中指定android:textIsSelectable="true"

           2.setSelected(true);

上面的随便使用一个即可,不需要重写TextView,也不需要一大堆的方法。Just Line of code!!这才像话!

原文地址:https://www.cnblogs.com/ufreedom/p/4248142.html