一、前言:
为了保持界面UI的整洁以及将尽可能多的内容显示在有限的空间中,往往需要将长度过长的TextView进行内容截取。本控件满足了TextView可在”完整内容”与”截取内容”两种模式下进行切换的需求,且可应用在ListView/RecyclerView中并可以动态更新内容。
注意:如果TextView中有span点击事件,看这篇文章:https://blog.csdn.net/stil_king/article/details/121731666
1、 静态截图如下:
2、动态效果图可点击如下链接:
流量党慎点
项目地址:
https://github.com/Carbs0126/ExpandableTextView
二、功能
1、主要功能:
1、限制行数,行尾添加ClickSpan,点击可以”展开”/”收起”两种状态切换;
2、可使用在ListView/RecyclerView中,效率较高;
3、可在任意时刻更新ExpandableTextView内容(布局显示之前或者显示之后);
4、可自定义行数限制,默认最多显示2行;
5、可自定义行尾ClickSpan是否显示,颜色,文字,按下的背景颜色;
6、可添加点击此view后是否在”展开”/”收起”状态间切换;
7、文字不足最大限制行数时,不截断文字,不显示末尾的”展开”/”收1、起”的指示标识;
8、可自定义行尾省略语与行尾”展开”/”收起”的指示标识之间的gap文字;
2、说明:
1、效果参考了jQuery的readmore.js,部分代码参考了ReadMoreTextView
2、与Github上star数最多的ExpandableTextView
实现原理及UI完全不同。
3、暂时未添加”收缩”/”展开”时的动画效果。
3、优化:
1、解决末尾显示的指示标识文字与原来文字宽度不一致时的显示问题(如原始文字与行尾指示标识文字为不同语言)。如当结尾指示标识文字较宽时,可能会显示到下一行。以此优化UI体验。
2、解决末尾单词过长或者跟随标点后,换行留下的空白问题。此问题源于TextView自带的一个属性:当结尾为完整单词或者跟随标点时会连同之前的部分文字一起换行。
3、解决文字过短时,截取文字超出边界的问题。
4、解决任何时刻为ExpandableTextView更新文字的问题。
4、不具有的功能:
1、限制字符长度。此控件只限制最大行数,不限制字符长度;
2、省略标识的位置自定义。省略标识的位置暂时只能显示在行尾,不能够指定是否在”行首”/”行中”/”行尾”
3、暂时未添加”收缩”/”展开”时的动画效果。
4、添加依赖
compile 'cn.carbs.android:ExpandableTextView:1.0.0'
5、使用方法
(1)在java中更新文字
//普通视图中的更新
etv.setText(text);
//在ListView/RecyclerView中的应用
etv.updateForRecyclerView(text, etvWidth, state);//etvWidth为控件的真实宽度,state是控件所处的状态,“收缩”/“伸展”状态
(2)在xml中直接设置文字
6、实现原理:
1、控件继承自TextView,TextView中的setText(CharSequence text)方法为 final 类型,且其内部最终调用了setText(CharSequence text, BufferType type),因此ExpandableTextView Override了setText(CharSequence text, BufferType type)方法,且TextView在通过xml布局文件设置text时,同样最终是通过setText(CharSequence text, BufferType type)进行赋值,因此通过Override此方法达到自定义显示text的效果;
2、采用android.text.Layout类来确定在一定宽度下,特定的文本所达到的行数,如果超过最大行数,则添加收缩/展开效果;
3、为文本特定位置添加ClickableSpan,以此添加点击部分文本的响应效果;自定义ClickableSpan和LinkMovementMethod,达到添加点击ClickableSpan文字背景颜色改变的效果,感谢stackoverflow的解答;
4、通过Paint.measureText(String text)方法,找到文本截取的最优位置,使得在行尾添加了ClickableSpan后,不会出现因文字宽度不同而导致的文本换行或者文本末尾空余过大的现象;
7、代码:
//点击事件
mETV = (ExpandableTextView)this.findViewById(R.id.etv);
// 测试添加OnClickListener的情况,功能正常。添加外部的onClick事件后,原来的点击toggle功能自动屏蔽,
// 点击尾部的ClickableSpan仍然有效
mETV.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View view) {
switch (mETV.getExpandState()){
case ExpandableTextView.STATE_SHRINK:
Toast.makeText(getApplicationContext(),"ExpandableTextView clicked, STATE_SHRINK",
Toast.LENGTH_SHORT).show();
break;
case ExpandableTextView.STATE_EXPAND:
Toast.makeText(getApplicationContext(),"ExpandableTextView clicked, STATE_EXPAND",
Toast.LENGTH_SHORT).show();
break;
}
}
});
mETV.setText(mPoems[0]);//在ExpandableTextView在创建完成之前改变文字,功能正常
package cn.carbs.android.expandabletextview.library;
/**
* Created by Carbs.Wang on 2016/7/16.
* website: https://github.com/Carbs0126/
*
* Thanks to :
* 1.ReadMoreTextView
* https://github.com/borjabravo10/ReadMoreTextView
* 2.TouchableSpan
* http://stackoverflow.com/questions
* /20856105/change-the-text-color-of-a-single-clickablespan-when-pressed-without-affecting-o
* 3.FlatUI
* http://www.bootcss.com/p/flat-ui/
*/
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.text.DynamicLayout;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.TextView;
import java.lang.reflect.Field;
public class ExpandableTextView extends TextView{
public static final int STATE_SHRINK = 0;
public static final int STATE_EXPAND = 1;
private static final String CLASS_NAME_VIEW = "android.view.View";
private static final String CLASS_NAME_LISTENER_INFO = "android.view.View$ListenerInfo";
private static final String ELLIPSIS_HINT = "..";
private static final String GAP_TO_EXPAND_HINT = " ";
private static final String GAP_TO_SHRINK_HINT = " ";
private static final int MAX_LINES_ON_SHRINK = 2;
private static final int TO_EXPAND_HINT_COLOR = 0xFF3498DB;
private static final int TO_SHRINK_HINT_COLOR = 0xFFE74C3C;
private static final int TO_EXPAND_HINT_COLOR_BG_PRESSED = 0x55999999;
private static final int TO_SHRINK_HINT_COLOR_BG_PRESSED = 0x55999999;
private static final boolean TOGGLE_ENABLE = true;
private static final boolean SHOW_TO_EXPAND_HINT = true;
private static final boolean SHOW_TO_SHRINK_HINT = true;
private String mEllipsisHint;
private String mToExpandHint;
private String mToShrinkHint;
private String mGapToExpandHint = GAP_TO_EXPAND_HINT;
private String mGapToShrinkHint = GAP_TO_SHRINK_HINT;
private boolean mToggleEnable = TOGGLE_ENABLE;
private boolean mShowToExpandHint = SHOW_TO_EXPAND_HINT;
private boolean mShowToShrinkHint = SHOW_TO_SHRINK_HINT;
private int mMaxLinesOnShrink = MAX_LINES_ON_SHRINK;
private int mToExpandHintColor = TO_EXPAND_HINT_COLOR;
private int mToShrinkHintColor = TO_SHRINK_HINT_COLOR;
private int mToExpandHintColorBgPressed = TO_EXPAND_HINT_COLOR_BG_PRESSED;
private int mToShrinkHintColorBgPressed = TO_SHRINK_HINT_COLOR_BG_PRESSED;
private int mCurrState = STATE_SHRINK;
// used to add to the tail of modified text, the "shrink" and "expand" text
private TouchableSpan mTouchableSpan;
private BufferType mBufferType = BufferType.NORMAL;
private TextPaint mTextPaint;
private Layout mLayout;
private int mTextLineCount = -1;
private int mLayoutWidth = 0;
private int mFutureTextViewWidth = 0;
// the original text of this view
private CharSequence mOrigText;
// used to judge if the listener of corresponding to the onclick event of ExpandableTextView
// is specifically for inner toggle
private ExpandableClickListener mExpandableClickListener;
private OnExpandListener mOnExpandListener;
public ExpandableTextView(Context context) {
super(context);
init();
}
public ExpandableTextView(Context context, AttributeSet attrs) {
super(context, attrs);
initAttr(context,attrs);
init();
}
public ExpandableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initAttr(context,attrs);
init();
}
private void initAttr(Context context, AttributeSet attrs) {
if (attrs == null) {
return;
}
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ExpandableTextView);
if (a == null) {
return;
}
int n = a.getIndexCount();
for (int i = 0; i < n; i++) {
int attr = a.getIndex(i);
if (attr == R.styleable.ExpandableTextView_etv_MaxLinesOnShrink) {
mMaxLinesOnShrink = a.getInteger(attr, MAX_LINES_ON_SHRINK);
}else if (attr == R.styleable.ExpandableTextView_etv_EllipsisHint){
mEllipsisHint = a.getString(attr);
}else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHint) {
mToExpandHint = a.getString(attr);
}else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHint) {
mToShrinkHint = a.getString(attr);
}else if (attr == R.styleable.ExpandableTextView_etv_EnableToggle) {
mToggleEnable = a.getBoolean(attr, TOGGLE_ENABLE);
}else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHintShow){
mShowToExpandHint = a.getBoolean(attr, SHOW_TO_EXPAND_HINT);
}else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHintShow){
mShowToShrinkHint = a.getBoolean(attr, SHOW_TO_SHRINK_HINT);
}else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHintColor){
mToExpandHintColor = a.getInteger(attr, TO_EXPAND_HINT_COLOR);
}else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHintColor){
mToShrinkHintColor = a.getInteger(attr, TO_SHRINK_HINT_COLOR);
}else if (attr == R.styleable.ExpandableTextView_etv_ToExpandHintColorBgPressed){
mToExpandHintColorBgPressed = a.getInteger(attr, TO_EXPAND_HINT_COLOR_BG_PRESSED);
}else if (attr == R.styleable.ExpandableTextView_etv_ToShrinkHintColorBgPressed){
mToShrinkHintColorBgPressed = a.getInteger(attr, TO_SHRINK_HINT_COLOR_BG_PRESSED);
}else if (attr == R.styleable.ExpandableTextView_etv_InitState){
mCurrState = a.getInteger(attr, STATE_SHRINK);
}else if (attr == R.styleable.ExpandableTextView_etv_GapToExpandHint){
mGapToExpandHint = a.getString(attr);
}else if (attr == R.styleable.ExpandableTextView_etv_GapToShrinkHint){
mGapToShrinkHint = a.getString(attr);
}
}
a.recycle();
}
private void init() {
mTouchableSpan = new TouchableSpan();
setMovementMethod(new LinkTouchMovementMethod());
if(TextUtils.isEmpty(mEllipsisHint)) {
mEllipsisHint = ELLIPSIS_HINT;
}
if(TextUtils.isEmpty(mToExpandHint)){
mToExpandHint = getResources().getString(R.string.to_expand_hint);
}
if(TextUtils.isEmpty(mToShrinkHint)){
mToShrinkHint = getResources().getString(R.string.to_shrink_hint);
}
if(mToggleEnable){
mExpandableClickListener = new ExpandableClickListener();
setOnClickListener(mExpandableClickListener);
}
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
ViewTreeObserver obs = getViewTreeObserver();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
obs.removeOnGlobalLayoutListener(this);
} else {
obs.removeGlobalOnLayoutListener(this);
}
setTextInternal(getNewTextByConfig(), mBufferType);
}
});
}
/**
* used in ListView or RecyclerView to update ExpandableTextView
* @param text
* original text
* @param futureTextViewWidth
* the width of ExpandableTextView in px unit,
* used to get max line number of original text by given the width
* @param expandState
* expand or shrink
*/
public void updateForRecyclerView(CharSequence text, int futureTextViewWidth, int expandState){
mFutureTextViewWidth = futureTextViewWidth;
mCurrState = expandState;
setText(text);
}
public void updateForRecyclerView(CharSequence text, BufferType type, int futureTextViewWidth){
mFutureTextViewWidth = futureTextViewWidth;
setText(text, type);
}
public void updateForRecyclerView(CharSequence text, int futureTextViewWidth){
mFutureTextViewWidth = futureTextViewWidth;
setText(text);
}
/**
* get the current state of ExpandableTextView
* @return
* STATE_SHRINK if in shrink state
* STATE_EXPAND if in expand state
*/
public int getExpandState(){
return mCurrState;
}
/**
* refresh and get a will-be-displayed text by current configuration
* @return
* get a will-be-displayed text
*/
private CharSequence getNewTextByConfig(){
if(TextUtils.isEmpty(mOrigText)){
return mOrigText;
}
mLayout = getLayout();
if(mLayout != null){
mLayoutWidth = mLayout.getWidth();
}
if(mLayoutWidth <= 0){
if(getWidth() == 0) {
if (mFutureTextViewWidth == 0) {
return mOrigText;
} else {
mLayoutWidth = mFutureTextViewWidth - getPaddingLeft() - getPaddingRight();
}
}else{
mLayoutWidth = getWidth() - getPaddingLeft() - getPaddingRight();
}
}
mTextPaint = getPaint();
mTextLineCount = -1;
switch (mCurrState){
case STATE_SHRINK: {
mLayout = new DynamicLayout(mOrigText, mTextPaint, mLayoutWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
mTextLineCount = mLayout.getLineCount();
if (mTextLineCount <= mMaxLinesOnShrink) {
return mOrigText;
}
int indexEnd = getValidLayout().getLineEnd(mMaxLinesOnShrink - 1);
int indexStart = getValidLayout().getLineStart(mMaxLinesOnShrink - 1);
int indexEndTrimmed = indexEnd
- getLengthOfString(mEllipsisHint)
- (mShowToExpandHint ? getLengthOfString(mToExpandHint) + getLengthOfString(mGapToExpandHint) : 0);
if (indexEndTrimmed <= 0) {
return mOrigText.subSequence(0, indexEnd);
}
int remainWidth = getValidLayout().getWidth() -
(int) (mTextPaint.measureText(mOrigText.subSequence(indexStart, indexEndTrimmed).toString()) + 0.5);
float widthTailReplaced = mTextPaint.measureText(getContentOfString(mEllipsisHint)
+ (mShowToExpandHint ? (getContentOfString(mToExpandHint) + getContentOfString(mGapToExpandHint)) : ""));
int indexEndTrimmedRevised = indexEndTrimmed;
if (remainWidth > widthTailReplaced) {
int extraOffset = 0;
int extraWidth = 0;
while (remainWidth > widthTailReplaced + extraWidth) {
extraOffset++;
if (indexEndTrimmed + extraOffset <= mOrigText.length()) {
extraWidth = (int) (mTextPaint.measureText(
mOrigText.subSequence(indexEndTrimmed, indexEndTrimmed + extraOffset).toString()) + 0.5);
} else {
break;
}
}
indexEndTrimmedRevised += extraOffset - 1;
} else {
int extraOffset = 0;
int extraWidth = 0;
while (remainWidth + extraWidth < widthTailReplaced) {
extraOffset--;
if (indexEndTrimmed + extraOffset > indexStart) {
extraWidth = (int) (mTextPaint.measureText(mOrigText.subSequence(indexEndTrimmed + extraOffset, indexEndTrimmed).toString()) + 0.5);
} else {
break;
}
}
indexEndTrimmedRevised += extraOffset;
}
SpannableStringBuilder ssbShrink = new SpannableStringBuilder(mOrigText, 0, indexEndTrimmedRevised)
.append(mEllipsisHint);
if (mShowToExpandHint) {
ssbShrink.append(getContentOfString(mGapToExpandHint) + getContentOfString(mToExpandHint));
ssbShrink.setSpan(mTouchableSpan, ssbShrink.length() - getLengthOfString(mToExpandHint), ssbShrink.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return ssbShrink;
}
case STATE_EXPAND: {
if (!mShowToShrinkHint) {
return mOrigText;
}
mLayout = new DynamicLayout(mOrigText, mTextPaint, mLayoutWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
mTextLineCount = mLayout.getLineCount();
if (mTextLineCount <= mMaxLinesOnShrink) {
return mOrigText;
}
SpannableStringBuilder ssbExpand = new SpannableStringBuilder(mOrigText)
.append(mGapToShrinkHint).append(mToShrinkHint);
ssbExpand.setSpan(mTouchableSpan, ssbExpand.length() - getLengthOfString(mToShrinkHint), ssbExpand.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return ssbExpand;
}
}
return mOrigText;
}
public void setExpandListener(OnExpandListener listener){
mOnExpandListener = listener;
}
private Layout getValidLayout(){
return mLayout != null ? mLayout : getLayout();
}
private void toggle(){
switch (mCurrState){
case STATE_SHRINK:
mCurrState = STATE_EXPAND;
if(mOnExpandListener != null){
mOnExpandListener.onExpand(this);
}
break;
case STATE_EXPAND:
mCurrState = STATE_SHRINK;
if(mOnExpandListener != null){
mOnExpandListener.onShrink(this);
}
break;
}
setTextInternal(getNewTextByConfig(), mBufferType);
}
@Override
public void setText(CharSequence text, BufferType type) {
mOrigText = text;
mBufferType = type;
setTextInternal(getNewTextByConfig(), type);
}
private void setTextInternal(CharSequence text, BufferType type){
super.setText(text, type);
}
private int getLengthOfString(String string){
if(string == null)
return 0;
return string.length();
}
private String getContentOfString(String string){
if(string == null)
return "";
return string;
}
public interface OnExpandListener{
void onExpand(ExpandableTextView view);
void onShrink(ExpandableTextView view);
}
private class ExpandableClickListener implements View.OnClickListener{
@Override
public void onClick(View view) {
toggle();
}
}
public View.OnClickListener getOnClickListener(View view) {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return getOnClickListenerV14(view);
} else {
return getOnClickListenerV(view);
}
}
private View.OnClickListener getOnClickListenerV(View view) {
View.OnClickListener retrievedListener = null;
try {
Field field = Class.forName(CLASS_NAME_VIEW).getDeclaredField("mOnClickListener");
field.setAccessible(true);
retrievedListener = (View.OnClickListener) field.get(view);
} catch (Exception e) {
e.printStackTrace();
}
return retrievedListener;
}
private View.OnClickListener getOnClickListenerV14(View view) {
View.OnClickListener retrievedListener = null;
try {
Field listenerField = Class.forName(CLASS_NAME_VIEW).getDeclaredField("mListenerInfo");
Object listenerInfo = null;
if (listenerField != null) {
listenerField.setAccessible(true);
listenerInfo = listenerField.get(view);
}
Field clickListenerField = Class.forName(CLASS_NAME_LISTENER_INFO).getDeclaredField("mOnClickListener");
if (clickListenerField != null && listenerInfo != null) {
clickListenerField.setAccessible(true);
retrievedListener = (View.OnClickListener) clickListenerField.get(listenerInfo);
}
} catch (Exception e) {
e.printStackTrace();
}
return retrievedListener;
}
/**
* Copy from:
* http://stackoverflow.com/questions
* /20856105/change-the-text-color-of-a-single-clickablespan-when-pressed-without-affecting-o
* By:
* Steven Meliopoulos
*/
private class TouchableSpan extends ClickableSpan {
private boolean mIsPressed;
public void setPressed(boolean isSelected) {
mIsPressed = isSelected;
}
@Override
public void onClick(View widget) {
if(hasOnClickListeners()
&& (getOnClickListener(ExpandableTextView.this) instanceof ExpandableClickListener)) {
}else{
toggle();
}
}
@Override
public void updateDrawState(TextPaint ds) {
super.updateDrawState(ds);
switch (mCurrState){
case STATE_SHRINK:
ds.setColor(mToExpandHintColor);
ds.bgColor = mIsPressed ? mToExpandHintColorBgPressed : 0;
break;
case STATE_EXPAND:
ds.setColor(mToShrinkHintColor);
ds.bgColor = mIsPressed ? mToShrinkHintColorBgPressed : 0;
break;
}
ds.setUnderlineText(false);
}
}
/**
* Copy from:
* http://stackoverflow.com/questions
* /20856105/change-the-text-color-of-a-single-clickablespan-when-pressed-without-affecting-o
* By:
* Steven Meliopoulos
*/
public class LinkTouchMovementMethod extends LinkMovementMethod {
private TouchableSpan mPressedSpan;
@Override
public boolean onTouchEvent(TextView textView, Spannable spannable, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mPressedSpan = getPressedSpan(textView, spannable, event);
if (mPressedSpan != null) {
mPressedSpan.setPressed(true);
Selection.setSelection(spannable, spannable.getSpanStart(mPressedSpan),
spannable.getSpanEnd(mPressedSpan));
}
} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
TouchableSpan touchedSpan = getPressedSpan(textView, spannable, event);
if (mPressedSpan != null && touchedSpan != mPressedSpan) {
mPressedSpan.setPressed(false);
mPressedSpan = null;
Selection.removeSelection(spannable);
}
} else {
if (mPressedSpan != null) {
mPressedSpan.setPressed(false);
super.onTouchEvent(textView, spannable, event);
}
mPressedSpan = null;
Selection.removeSelection(spannable);
}
return true;
}
private TouchableSpan getPressedSpan(TextView textView, Spannable spannable, MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= textView.getTotalPaddingLeft();
y -= textView.getTotalPaddingTop();
x += textView.getScrollX();
y += textView.getScrollY();
Layout layout = textView.getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
TouchableSpan[] link = spannable.getSpans(off, off, TouchableSpan.class);
TouchableSpan touchedSpan = null;
if (link.length > 0) {
touchedSpan = link[0];
}
return touchedSpan;
}
}
}
library
ExpandableTextView in ListView
ExpandableTextView
Expand
Shrink
原文链接:https://blog.csdn.net/mp624183768/article/details/79052041