不断学习,做更好的自己!💪

前言

我们经常会碰到这样一个需求:文本内容过多,可以展开和收起。

效果图

【Android 自定义 View】-->TextView 的展开 & 收起(文本折叠)_收起
【Android 自定义 View】-->TextView 的展开 & 收起(文本折叠)_文字折叠_02
注意

  • 显示 “…展开” 时,是截取的一定行数之后,在最后一行的末尾直接显示;
  • “收起” 显示在全部文本的下一行,并且是右对齐;
  • 展开和收起的动画效果。

代码实现

1. ExpandTextView.java

public class ExpandTextView extends AppCompatTextView {
public static final String ELLIPSIS_STRING = new String(new char[]{'\u2026'});
private static final int DEFAULT_MAX_LINE = 3;
private static final String DEFAULT_OPEN_SUFFIX = " 展开";
private static final String DEFAULT_CLOSE_SUFFIX = " 收起";
volatile boolean animating = false;
boolean isClosed = false;
private int mMaxLines = DEFAULT_MAX_LINE;
/** TextView可展示宽度,包含paddingLeft和paddingRight */
private int initWidth = 0;
/** 原始文本 */
private CharSequence originalText;

private SpannableStringBuilder mOpenSpannableStr, mCloseSpannableStr;

private boolean hasAnimation = false;
private Animation mOpenAnim, mCloseAnim;
private int mOpenHeight, mCLoseHeight;
private boolean mExpandable;
private boolean mCloseInNewLine;
@Nullable
private SpannableString mOpenSuffixSpan, mCloseSuffixSpan;
private String mOpenSuffixStr = DEFAULT_OPEN_SUFFIX;
private String mCloseSuffixStr = DEFAULT_CLOSE_SUFFIX;
private int mOpenSuffixColor, mCloseSuffixColor;

private View.OnClickListener mOnClickListener;

private CharSequenceToSpannableHandler mCharSequenceToSpannableHandler;

public ExpandTextView(Context context) {
this(context,null);
}

public ExpandTextView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}

public ExpandTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}

/** 初始化 */
private void initialize() {
mOpenSuffixColor = mCloseSuffixColor = Color.parseColor("#F23030");
setMovementMethod(OverLinkMovementMethod.getInstance());
setIncludeFontPadding(false);
updateOpenSuffixSpan();
updateCloseSuffixSpan();
}

@Override
public boolean hasOverlappingRendering() {
return false;
}

public void setOriginalText(CharSequence originalText) {
this.originalText = originalText;
mExpandable = false;
mCloseSpannableStr = new SpannableStringBuilder();
final int maxLines = mMaxLines;
SpannableStringBuilder tempText = charSequenceToSpannable(originalText);
mOpenSpannableStr = charSequenceToSpannable(originalText);

if (maxLines != -1) {
Layout layout = createStaticLayout(tempText);
mExpandable = layout.getLineCount() > maxLines;
if (mExpandable) {
//拼接展开内容
if (mCloseInNewLine) {
mOpenSpannableStr.append("\n");
}
if (mCloseSuffixSpan != null) {
mOpenSpannableStr.append(mCloseSuffixSpan);
}
//计算原文截取位置
int endPos = layout.getLineEnd(maxLines - 1);
if (originalText.length() <= endPos) {
mCloseSpannableStr = charSequenceToSpannable(originalText);
} else {
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, endPos));
}
SpannableStringBuilder tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
tempText2.append(mOpenSuffixSpan);
}
//循环判断,收起内容添加展开后缀后的内容
Layout tempLayout = createStaticLayout(tempText2);
while (tempLayout.getLineCount() > maxLines) {
int lastSpace = mCloseSpannableStr.length() - 1;
if (lastSpace == -1) {
break;
}
if (originalText.length() <= lastSpace) {
mCloseSpannableStr = charSequenceToSpannable(originalText);
} else {
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
}
tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
tempText2.append(mOpenSuffixSpan);
}
tempLayout = createStaticLayout(tempText2);

}
int lastSpace = mCloseSpannableStr.length() - mOpenSuffixSpan.length();
if(lastSpace >= 0 && originalText.length() > lastSpace){
CharSequence redundantChar = originalText.subSequence(lastSpace, lastSpace + mOpenSuffixSpan.length());
int offset = hasEnCharCount(redundantChar) - hasEnCharCount(mOpenSuffixSpan) + 1;
lastSpace = offset <= 0 ? lastSpace : lastSpace - offset;
mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace));
}
//计算收起的文本高度
mCLoseHeight = tempLayout.getHeight() + getPaddingTop() + getPaddingBottom();

mCloseSpannableStr.append(ELLIPSIS_STRING);
if (mOpenSuffixSpan != null) {
mCloseSpannableStr.append(mOpenSuffixSpan);
}
}
}
isClosed = mExpandable;
if (mExpandable) {
setText(mCloseSpannableStr);
//设置监听
super.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
// switchOpenClose();
// if (mOnClickListener != null) {
// mOnClickListener.onClick(v);
// }
}
});
} else {
setText(mOpenSpannableStr);
}
}

private int hasEnCharCount(CharSequence str){
int count = 0;
if(!TextUtils.isEmpty(str)){
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if(c >= ' ' && c <= '~'){
count++;
}
}
}
return count;
}

private void switchOpenClose() {
if (mExpandable) {
isClosed = !isClosed;
if (isClosed) {
close();
} else {
open();
}
}
}

/**
* 设置是否有动画
*
* @param hasAnimation
*/
public void setHasAnimation(boolean hasAnimation) {
this.hasAnimation = hasAnimation;
}

/** 展开 */
private void open() {
if (hasAnimation) {
Layout layout = createStaticLayout(mOpenSpannableStr);
mOpenHeight = layout.getHeight() + getPaddingTop() + getPaddingBottom();
executeOpenAnim();
} else {
ExpandTextView.super.setMaxLines(Integer.MAX_VALUE);
setText(mOpenSpannableStr);
if (mOpenCloseCallback != null){
mOpenCloseCallback.onOpen();
}
}
}

/** 收起 */
private void close() {
if (hasAnimation) {
executeCloseAnim();
} else {
ExpandTextView.super.setMaxLines(mMaxLines);
setText(mCloseSpannableStr);
if (mOpenCloseCallback != null){
mOpenCloseCallback.onClose();
}
}
}

/** 执行展开动画 */
private void executeOpenAnim() {
//创建展开动画
if (mOpenAnim == null) {
mOpenAnim = new ExpandCollapseAnimation(this, mCLoseHeight, mOpenHeight);
mOpenAnim.setFillAfter(true);
mOpenAnim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
ExpandTextView.super.setMaxLines(Integer.MAX_VALUE);
setText(mOpenSpannableStr);
}

@Override
public void onAnimationEnd(Animation animation) {
// 动画结束后textview设置展开的状态
getLayoutParams().height = mOpenHeight;
requestLayout();
animating = false;
}

@Override
public void onAnimationRepeat(Animation animation) {

}
});
}

if (animating) {
return;
}
animating = true;
clearAnimation();
// 执行动画
startAnimation(mOpenAnim);
}

/** 执行收起动画 */
private void executeCloseAnim() {
//创建收起动画
if (mCloseAnim == null) {
mCloseAnim = new ExpandCollapseAnimation(this, mOpenHeight, mCLoseHeight);
mCloseAnim.setFillAfter(true);
mCloseAnim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {

}

@Override
public void onAnimationEnd(Animation animation) {
animating = false;
ExpandTextView.super.setMaxLines(mMaxLines);
setText(mCloseSpannableStr);
getLayoutParams().height = mCLoseHeight;
requestLayout();
}

@Override
public void onAnimationRepeat(Animation animation) {

}
});
}

if (animating) {
return;
}
animating = true;
clearAnimation();
// 执行动画
startAnimation(mCloseAnim);
}

/**
* @param spannable
*
* @return
*/
private Layout createStaticLayout(SpannableStringBuilder spannable) {
int contentWidth = initWidth - getPaddingLeft() - getPaddingRight();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
StaticLayout.Builder builder = StaticLayout.Builder.obtain(spannable, 0, spannable.length(), getPaint(), contentWidth);
builder.setAlignment(Layout.Alignment.ALIGN_NORMAL);
builder.setIncludePad(getIncludeFontPadding());
builder.setLineSpacing(getLineSpacingExtra(), getLineSpacingMultiplier());
return builder.build();
}else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL,
getLineSpacingMultiplier(), getLineSpacingExtra(), getIncludeFontPadding());
}else{
return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL,
getFloatField("mSpacingMult",1f), getFloatField("mSpacingAdd",0f), getIncludeFontPadding());
}
}

private float getFloatField(String fieldName,float defaultValue){
float value = defaultValue;
if(TextUtils.isEmpty(fieldName)){
return value;
}
try {
// 获取该类的所有属性值域
Field[] fields = this.getClass().getDeclaredFields();
for (Field field:fields) {
if(TextUtils.equals(fieldName,field.getName())){
value = field.getFloat(this);
break;
}
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return value;
}


/**
* @param charSequence
*
* @return
*/
private SpannableStringBuilder charSequenceToSpannable(@NonNull CharSequence charSequence) {
SpannableStringBuilder spannableStringBuilder = null;
if (mCharSequenceToSpannableHandler != null) {
spannableStringBuilder = mCharSequenceToSpannableHandler.charSequenceToSpannable(charSequence);
}
if (spannableStringBuilder == null) {
spannableStringBuilder = new SpannableStringBuilder(charSequence);
}
return spannableStringBuilder;
}

/**
* 初始化TextView的可展示宽度
*
* @param width
*/
public void initWidth(int width) {
initWidth = width;
}

@Override
public void setMaxLines(int maxLines) {
this.mMaxLines = maxLines;
super.setMaxLines(maxLines);
}

/**
* 设置展开后缀text
*
* @param openSuffix
*/
public void setOpenSuffix(String openSuffix) {
mOpenSuffixStr = openSuffix;
updateOpenSuffixSpan();
}

/**
* 设置展开后缀文本颜色
*
* @param openSuffixColor
*/
public void setOpenSuffixColor(@ColorInt int openSuffixColor) {
mOpenSuffixColor = openSuffixColor;
updateOpenSuffixSpan();
}

/**
* 设置收起后缀text
*
* @param closeSuffix
*/
public void setCloseSuffix(String closeSuffix) {
mCloseSuffixStr = closeSuffix;
updateCloseSuffixSpan();
}

/**
* 设置收起后缀文本颜色
*
* @param closeSuffixColor
*/
public void setCloseSuffixColor(@ColorInt int closeSuffixColor) {
mCloseSuffixColor = closeSuffixColor;
updateCloseSuffixSpan();
}

/**
* 收起后缀是否另起一行
*
* @param closeInNewLine
*/
public void setCloseInNewLine(boolean closeInNewLine) {
mCloseInNewLine = closeInNewLine;
updateCloseSuffixSpan();
}

/** 更新展开后缀Spannable */
private void updateOpenSuffixSpan() {
if (TextUtils.isEmpty(mOpenSuffixStr)) {
mOpenSuffixSpan = null;
return;
}
mOpenSuffixSpan = new SpannableString(mOpenSuffixStr);
mOpenSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
mOpenSuffixSpan.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
switchOpenClose();
}

@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(mOpenSuffixColor);
ds.setUnderlineText(false);
}
},0, mOpenSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
}

/** 更新收起后缀Spannable */
private void updateCloseSuffixSpan() {
if (TextUtils.isEmpty(mCloseSuffixStr)) {
mCloseSuffixSpan = null;
return;
}
mCloseSuffixSpan = new SpannableString(mCloseSuffixStr);
mCloseSuffixSpan.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), 0, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (mCloseInNewLine) {
AlignmentSpan alignmentSpan = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE);
mCloseSuffixSpan.setSpan(alignmentSpan, 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
mCloseSuffixSpan.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
switchOpenClose();
}

@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setColor(mCloseSuffixColor);
ds.setUnderlineText(false);
}
},1, mCloseSuffixStr.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}

@Override
public void setOnClickListener(View.OnClickListener onClickListener) {
mOnClickListener = onClickListener;
}

public OpenAndCloseCallback mOpenCloseCallback;
public void setOpenAndCloseCallback(OpenAndCloseCallback callback){
this.mOpenCloseCallback = callback;
}

public interface OpenAndCloseCallback{
void onOpen();
void onClose();
}
/**
* 设置文本内容处理
*
* @param handler
*/
public void setCharSequenceToSpannableHandler(CharSequenceToSpannableHandler handler) {
mCharSequenceToSpannableHandler = handler;
}

public interface CharSequenceToSpannableHandler {
@NonNull
SpannableStringBuilder charSequenceToSpannable(CharSequence charSequence);
}

class ExpandCollapseAnimation extends Animation {
private final View mTargetView;//动画执行view
private final int mStartHeight;//动画执行的开始高度
private final int mEndHeight;//动画结束后的高度

ExpandCollapseAnimation(View target, int startHeight, int endHeight) {
mTargetView = target;
mStartHeight = startHeight;
mEndHeight = endHeight;
setDuration(400);
}

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
mTargetView.setScrollY(0);
//计算出每次应该显示的高度,改变执行view的高度,实现动画
mTargetView.getLayoutParams().height = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight);
mTargetView.requestLayout();
}
}
}

2. OverLinkMovementMethod.java

public class OverLinkMovementMethod extends LinkMovementMethod {
public static boolean canScroll = false;

@Override
public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
int action = event.getAction();
if(action == MotionEvent.ACTION_MOVE){
if(!canScroll){
return true;
}
}
return super.onTouchEvent(widget, buffer, event);
}

public static MovementMethod getInstance() {
if (sInstance == null)
sInstance = new OverLinkMovementMethod();

return sInstance;
}

private static OverLinkMovementMethod sInstance;

private static Object FROM_BELOW = new NoCopySpan.Concrete();
}

3. 使用

  • 布局文件
<com.hkt.bottomsimples.widget.ExpandTextView
android:id="@+id/etv_content"
android:layout_margin="10dp"
android:layout_width="0dp"
android:layout_height="wrap_content"
tools:text="Hello World!"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
  • 代码使用
public class WidgetActivity extends AppCompatActivity {
@BindView(R.id.etv_content)
ExpandTextView mContent;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_widget);

ButterKnife.bind(this);

int width = getWindowManager().getDefaultDisplay().getWidth() - dp2px(this, 20f);
mContent.initWidth(width);
mContent.setMaxLines(3);
mContent.setHasAnimation(true);
mContent.setCloseInNewLine(true);
mContent.setOpenSuffixColor(getResources().getColor(R.color.teal_200));
mContent.setCloseSuffixColor(getResources().getColor(R.color.teal_200));
mContent.setOriginalText("世界上总有一些人的出生是不被期待的。\n" +
"在那个年代如火如荼的计划生育中,总有一两条漏网之鱼。\n" +
"南方的春天阴雨绵绵,空气中弥漫着浓郁的湿气,让人不由得觉得烦躁,难受。\n" +
"一个女人挺着大肚子,东躲西藏,四处打听,吃尽了闭门羹。所幸皇天不负有心人,\n " +
"几番周折后,她终于在临盆前的一天找到了愿意接生的医生。"
);
}

public static int dp2px(Context context, float dpValue) {
int res = 0;
final float scale = context.getResources().getDisplayMetrics().density;
if (dpValue < 0)
res = -(int) (-dpValue * scale + 0.5f);
else
res = (int) (dpValue * scale + 0.5f);
return res;
}
}