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

2023-02-12,,,

说到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)即可。

总结:

跑马灯效果的限制条件:

    是否能获得焦点或者是否被选中
    文本内容宽度是否大于TextView整个的宽度(是否能显示完)

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

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

2.setSelected(true);

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

浅谈TextView Ellipsize效果与Marquee跑马灯无效果问题的相关教程结束。

《浅谈TextView Ellipsize效果与Marquee跑马灯无效果问题.doc》

下载本文的Word格式文档,以方便收藏与打印。