ExpandLayout是支持在TextView行末添加点击展开更多或收缩文本的布局,支持点击查看全部和收起功能,同时提供了大量自定义属性以支持更多的个性化属性配置,效果展示GIF如下,由于录制工具的问题,视频转换为GIF结束后会显示一段黑屏,大家勿喷,忽略就好~
在接到相关需求时,也在网上参考了一些TextView点击展开更多/收缩文本的例子,大多都是在TextView文本最后一行接着显示展开或收缩文本或图片的实现方案,大多是通过SpannableString或子类直接拼接操作即可,很少有在文本所在布局右底下显示点击展开更多/收缩布局的实现,而且部分没有处理好文本分行,存在英文单词整体换行到下一行显示而导致重叠的问题,所以本人决定直接撸了一个,也在项目中的实践中不断的优化和完善,并整理成文,分享出来,希望对大家有参考和学习作用。
ExpandLayout支持如下特性:
1、文本行末更多布局支持图标+文字、图标、文字三种样式,默认是图标+文字样式;
2、支持配置展开和收缩提示图标,展开和收缩的显示的提示文字;
3、支持配置内容显示文本、行末展开/收缩提示文字的字体大小、字体颜色、行间距等;
4、支持设置缩略文本展示时与展开/收缩布局的间距;
5、支持收缩和展示状态的回调监听;
6、支持设置缩略文本展示的最大行数,并处理存在换行符时的特殊情况
实现思路和步骤:
1)自定义样式布局和属性,以满足在TextView行末显示展开和收缩提示布局
2)继承RelativeLayout获取相应控件和以及自定义属性,完成属性和布局控件初始化
3)根据设置的文本,获取控件宽度,根据配置的自定义属性,计算并截取要显示的缩略显示的文本
上述步骤中,个人感觉难点在于如下三点:
1)如何在TextView显示文本前,获取文本展示的行数是否超过了设置的最大缩略展示行数
2)如何在TextView显示文本前,根据要显示的文本获取要最后一行文字的长度和字符下标
3)如何保证展开和收缩提示布局的图标与文字与显示内容最后一行文字保持居中显示,而且最后一行文字与展开/收缩提示布局不重叠
下面根据这些步骤及问题进行一一分析和处理
1、声明布局,满足在TextView行末显示展开和收缩提示布局,xml布局如下:
在布局中,整体采用FrameLayout,其中显示的文本expand_content_tv填充占满整个布局,可展开/收缩提示布局expand_ll在FrameLayout右下角展示,可能在这里很多朋友有疑问,这样子显示的最后一行文字与展开/收缩提示布局不就是重叠在一起了吗?在预览显示的效果,文本与展开收缩提示布局是存在重叠的,需要在代码中进行文本截取,下面也会进行讲解,预览结果如下:
在这里,为了保证展开和收缩提示布局的图标与文字与显示内容最后一行文字保持居中显示(也是上面提到的第3个问题里面),在布局中添加了可见状态为invisible的expand_helper_tv辅助TextView,其属性设置设置保持与内容TextView一致,同时设置辅助TextView与展示/收缩图标和文字三者保持居中显示,即可实现展开和收缩提示布局的图标与文字与显示内容最后一行文字保持居中显示。
2、自定义属性
在attrs.xml通过declare-styleable根标签定义可以配置的自定义属性,每个属性的含义请见注释
3、继承RelativeLayout获取相应控件和以及自定义属性,完成属性和布局控件初始化
public class ExpandLayout extends RelativeLayout implements View.OnClickListener {
public ExpandLayout(Context context) {
this(context, null);
}
public ExpandLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ExpandLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mContext = context;
initAttributeSet(context, attrs);
initView();
}
/**
* 初始化自定义属性
* @param context
* @param attrs
*/
private void initAttributeSet(Context context, AttributeSet attrs){
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ExpandLayout);
if (ta != null) {
mMaxLines = ta.getInt(R.styleable.ExpandLayout_maxLines, 2);
mExpandIconResId = ta.getResourceId(R.styleable.ExpandLayout_expandIconResId, 0);
mCollapseIconResId = ta.getResourceId(R.styleable.ExpandLayout_collapseIconResId, 0);
mExpandMoreStr = ta.getString(R.styleable.ExpandLayout_expandMoreText);
mCollapseLessStr = ta.getString(R.styleable.ExpandLayout_collapseLessText);
mContentTextSize = ta.getDimensionPixelSize(R.styleable.ExpandLayout_contentTextSize, sp2px(context, 14));
mContentTextColor = ta.getColor(R.styleable.ExpandLayout_contentTextColor, 0);
mExpandTextSize = ta.getDimensionPixelSize(R.styleable.ExpandLayout_expandTextSize, sp2px(context, 14));
mExpandTextColor = ta.getColor(R.styleable.ExpandLayout_expandTextColor, 0);
mExpandStyle = ta.getInt(R.styleable.ExpandLayout_expandStyle, STYLE_DEFAULT);
mExpandIconWidth = ta.getDimensionPixelSize(R.styleable.ExpandLayout_expandIconWidth, dp2px(context, 15));
mSpaceMargin = ta.getDimensionPixelSize(R.styleable.ExpandLayout_spaceMargin, dp2px(context, 20));
mLineSpacingExtra = ta.getDimensionPixelSize(R.styleable.ExpandLayout_lineSpacingExtra, 0);
mLineSpacingMultiplier = ta.getFloat(R.styleable.ExpandLayout_lineSpacingMultiplier, 1.0f);
ta.recycle();
}
// mMaxLines应该保证大于等于1
if (mMaxLines < 1) {
mMaxLines = 1;
}
}
/**
* 初始化View
*/
private void initView() {
mRootView = inflate(mContext, R.layout.layout_expand, this);
mTvContent = findViewById(R.id.expand_content_tv);
mLayoutExpandMore = findViewById(R.id.expand_ll);
mIconExpand = findViewById(R.id.expand_iv);
mTvExpand = findViewById(R.id.expand_tv);
mTvExpandHelper = findViewById(R.id.expand_helper_tv);
mTvExpand.setText(mExpandMoreStr);
mTvContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, mContentTextSize);
// 辅助TextView,与内容TextView大小相等,保证末尾图标和文字与内容文字居中显示
mTvExpandHelper.setTextSize(TypedValue.COMPLEX_UNIT_PX, mContentTextSize);
mTvExpand.setTextSize(TypedValue.COMPLEX_UNIT_PX, mExpandTextSize);
mTvContent.setLineSpacing(mLineSpacingExtra, mLineSpacingMultiplier);
mTvExpandHelper.setLineSpacing(mLineSpacingExtra, mLineSpacingMultiplier);
mTvExpand.setLineSpacing(mLineSpacingExtra, mLineSpacingMultiplier);
//默认设置展开图标
setExpandMoreIcon(mExpandIconResId);
setContentTextColor(mContentTextColor);
setExpandTextColor(mExpandTextColor);
switch (mExpandStyle) {
case STYLE_ICON:
mIconExpand.setVisibility(VISIBLE);
mTvExpand.setVisibility(GONE);
break;
case STYLE_TEXT:
mIconExpand.setVisibility(GONE);
mTvExpand.setVisibility(VISIBLE);
break;
default:
mIconExpand.setVisibility(VISIBLE);
mTvExpand.setVisibility(VISIBLE);
break;
}
}
上面主要是通过继承RelativeLayout,并通过obtainStyledAttributes()以及inflate()方法完成自定义属性的初始化以及要显示布局的加载以及控件的获取,在这里只贴出关键部分代码,完整代码请将本人GitHub项目,链接请见在文章尾部
4、根据设置的文本,获取控件宽度,根据配置的自定义属性,计算并截取要显示的缩略显示的文本
在上面提及的三个问题中,都是与本步骤相关,下面也分点进行处理和分析,即
1)如何在TextView显示文本前,获取文本展示的行数是否超过了设置的最大缩略展示行数
2)如何在TextView显示文本前,根据要显示的文本获取要最后一行文字的长度和字符下标
3)如何保证最后一行文字与展开/收缩提示布局不重叠
A、如何获取文本显示的宽度?
在设置文本时,需要先确定文本显示的宽度,通过上面布局xml已经可以看到,在本实现中,TextView控件的宽度与外层布局宽度一致,也就是文本显示的宽度与外层布局测量宽度大小一样,只需要测量出外层布局宽度,就可以得出文本显示的宽度,获取控件的宽度也有很多方法,本文采用View的ViewTreeObserver来获取控件的宽度,通过给控件添加相应OnGlobalLayoutListener监听器,在相应的回调方法中获取布局的测量宽度即可,如下面所示:
/**
* 设置文本内容
*
* @param contentStr
* @param onExpandStateChangeListener 状态回调监听器
*/
public void setContent(String contentStr, final OnExpandStateChangeListener onExpandStateChangeListener) {
if (TextUtils.isEmpty(contentStr) || mRootView == null) {
return;
}
mOriginContentStr = contentStr;
mOnExpandStateChangeListener = onExpandStateChangeListener;
// 此处需要先设置mTvContent的text属性,防止在列表中,由于没有获取到控件宽度mMeasuredWidth,先执行onMeasure方法测量时,导致文本只能显示一行的问题
// 提前设置好text,再执行onMeasure,则没有该问题
mTvContent.setMaxLines(mMaxLines);
mTvContent.setText(mOriginContentStr);
// 获取文字的宽度
if (mMeasuredWidth <= 0) {
Log.d(TAG, "宽度尚未获取到,第一次加载");
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 用完后立即移除监听,防止多次回调的问题
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
getViewTreeObserver().removeOnGlobalLayoutListener(this);
} else {
getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
mMeasuredWidth = getMeasuredWidth();
Log.d(TAG, "onGlobalLayout,控件宽度 = " + mMeasuredWidth);
measureEllipsizeText(mMeasuredWidth);
}
});
} else {
Log.d(TAG, "宽度已获取到,非第一次加载");
measureEllipsizeText(mMeasuredWidth);
}
}
上面代码执行逻辑主要是:
1)如果没有获取到文本显示宽度,通过给根布局的ViewTreeObserver添加OnGlobalLayoutListener监听器,在onGlobalLayout()回调时,也即布局已经测量完成时,获取根布局的宽度,再根据文本显示宽度执行measureEllipsizeText(int)去处理文本测量和分行处理,需要注意的是,OnGlobalLayoutListener监听会回调多次,需要在第一次监听回调后,remove移除OnGlobalLayoutListener的监听,而且不同SDK版本方法不一样,具体请参见代码;
2)如果先前已确定文本宽度,直接执行measureEllipsizeText(int)去处理文本测量和分行处理。
在获取文本显示的宽度遇到的问题:
在实际项目使用时,发现通过上述方法,在普通布局使用时,可以正常获取到文本显示的宽度,但在ListView或RecyclerView时,第一屏的Item中的ExpandLayout也可以正常获取到文本的宽度,但往下滚动时,由于Item的复用,导致后面加载显示的Item,在给根布局的ViewTreeObserver添加OnGlobalLayoutListener监听器时,无法正常回调onGlobalLayout(),导致无法显示文本内容,针对这个问题,发现后加载的Item的ExpandLayout会正常回调执行onMeasure(int widthMeasureSpec, int heightMeasureSpec),因此考虑在这个时机,会获取文本显示宽度,从而解决了在ListView或RecyclerView中部分Item的ExpandLayout无法正常回调onGlobalLayout(),导致无法显示文本内容的问题:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
Log.d(TAG, "onMeasure,measureWidth = " + getMeasuredWidth());
if (mMeasuredWidth <= 0 && getMeasuredWidth() > 0) {
mMeasuredWidth = getMeasuredWidth();
measureEllipsizeText(mMeasuredWidth);
}
}
B、如何在TextView显示文本前,获取文本展示的行数
这里主要是利用了TextView文本排版处理,TextView中提供了三种文本处理工具辅助类:
a、BoringLayout 主要适合单行文本显示的情况 ;
b、DynamicLayout 主要特性是在支持多行的情况下最关键的特性是监听文本的改动,性能略低StaticLayout,静态文本显示 不建议使用这个;
c、StaticLayout 顾名思义特点适合多行静态文本显示的情况
由于app当中很大部分文本都是静态显示的效果,所以本文主要使用的就是StaticLayout来查理TextView文本的排查以及拆行,以到达没有进行TextView.setText()设置显示文本时,通过TextView的TextPaint以及要显示文本等设置参数,获取到文本排版情况,其中,通过StaticLayout.getLineCount()方法获取文本显示行数,如下代码所示,具体完整代码请参见项目代码:
/**
* 使用StaticLayout处理文本分行和布局
*
* @param lineWidth 文本(布局)宽度
*/
private void handleMeasureEllipsizeText(int lineWidth) {
TextPaint textPaint = mTvContent.getPaint();
StaticLayout staticLayout = new StaticLayout(mOriginContentStr, textPaint, lineWidth, Layout.Alignment.ALIGN_NORMAL, mLineSpacingMultiplier, mLineSpacingExtra, false);
int lineCount = staticLayout.getLineCount();
if (lineCount <= mMaxLines) {
// 不足最大行数,直接设置文本
//少于最小展示行数,不再展示更多相关布局
mEllipsizeStr = mOriginContentStr;
mLayoutExpandMore.setVisibility(View.GONE);
mTvContent.setMaxLines(Integer.MAX_VALUE);
mTvContent.setText(mOriginContentStr);
} else {
// 超出最大行数
mRootView.setOnClickListener(this);
mLayoutExpandMore.setVisibility(View.VISIBLE);
// Step1:第mMinLineNum行的处理
handleEllipsizeString(staticLayout, lineWidth);
// Step2:最后一行的处理
handleLastLine(staticLayout, lineWidth);
.......
}
}
C、如何在TextView显示文本前,根据要显示的文本获取要最后一行文字的长度和字符下标
这里主要是利用了StaticLayout以及TextPaint相关接口方法来获取某一行文本以及文本长度或下标,主要思路如下:
1)通过StaticLayout的getLineStart(int)以及getLineEnd(int)方法,可以分别获取到某一行的在整个文本中的起始和结束下标;
2)在得到起始index和结束index后,再通过String的取子串方法substring(int,int)即可获取到某一行要显示的文本
3)最后再利用TextView的TextPaint的measureText()方法,即可获取到该行文本的长度。
如下处理展开时,最后一行文本处理代码所示,具体完整代码请参见项目代码:
/**
* 处理最后一行文本
*
* @param staticLayout
* @param lineWidth
*/
private void handleLastLine(StaticLayout staticLayout, int lineWidth) {
if (staticLayout == null) {
return;
}
int lineCount = staticLayout.getLineCount();
if (lineCount < 1) {
return;
}
int startPos = staticLayout.getLineStart(lineCount - 1);
int endPos = staticLayout.getLineEnd(lineCount - 1);
Log.d(TAG, "最后一行 startPos = " + startPos);
Log.d(TAG, "最后一行 endPos = " + endPos);
// 修正,防止取子串越界
if (startPos < 0) {
startPos = 0;
}
if (endPos > mOriginContentStr.length()) {
endPos = mOriginContentStr.length();
}
if (startPos > endPos) {
startPos = endPos;
}
String lastLineContent = mOriginContentStr.substring(startPos, endPos);
Log.d(TAG, "最后一行 内容 = " + lastLineContent);
float textLength = 0f;
TextPaint textPaint = mTvContent.getPaint();
if (lastLineContent != null) {
textLength = textPaint.measureText(lastLineContent);
}
Log.d(TAG, "最后一行 文本长度 = " + textLength);
float reservedWidth = getExpandLayoutReservedWidth();
if (textLength + reservedWidth > lineWidth) {
//文字宽度+展开布局的宽度 > 一行最大展示宽度
//换行展示“收起”按钮及文字
mOriginContentStr += "\n";
}
}
D、如何保证最后一行文字与展开/收缩提示布局不重叠
在前面的问题中已描述得出,我们可以获取到某一行显示文本的文本以及文本宽度,有了这个理论支持后,我们需要考虑如下文本分别在展开和收起两种状态下,最后一行文字与展开/收缩提示布局不重叠:
1、在文本收缩时,需要根据每行文本显示的最大测量宽度lineWidth、缩略时最后一行显示的文本宽度textLength、展开/收缩提示布局宽度expandTipLayoutWidth以及两者之间设置的间距mSpaceMargin来综合考虑:
需要预留的固定宽度为reservedWidth:"..." + 展开布局与文本间距 +图标长度 + 展开文本长度,即"..."文本的测量宽度+ mSpaceMargin + expandTipLayoutWidth;
则控件收缩展示时,最后一行文本显示的最大长度为每行文本显示的最大测量宽度lineWidth - 需要预留的固定宽度为reservedWidth,
在收缩显示情况下,此时有两种情况:
1)如果最后一行文本显示的最大长度比缩略时最后一行显示的文本宽度textLength相等或还要大,则证明最后一行文本可以完整显示
2)如果最后一行文本显示的最大长度比缩略时最后一行显示的文本宽度textLength还要小,则证明最后一行文本不可以完整显示,需要在进行等比例截取
如下代码所示,具体完整代码请参见项目代码:
/**
* 处理缩略的字符串
*
* @param staticLayout
* @param lineWidth
*/
private void handleEllipsizeString(StaticLayout staticLayout, int lineWidth) {
if (staticLayout == null) {
return;
}
TextPaint textPaint = mTvContent.getPaint();
// 获取到第mMinLineNum行的起始和结束位置
int startPos = staticLayout.getLineStart(mMaxLines - 1);
int endPos = staticLayout.getLineEnd(mMaxLines - 1);
Log.d(TAG, "startPos = " + startPos);
Log.d(TAG, "endPos = " + endPos);
// 修正,防止取子串越界
if (startPos < 0) {
startPos = 0;
}
if (endPos > mOriginContentStr.length()) {
endPos = mOriginContentStr.length();
}
if (startPos > endPos) {
startPos = endPos;
}
String lineContent = mOriginContentStr.substring(startPos, endPos);
float textLength = 0f;
if (lineContent != null) {
textLength = textPaint.measureText(lineContent);
}
Log.d(TAG, "第" + mMaxLines + "行 = " + lineContent);
Log.d(TAG, "第" + mMaxLines + "行 文本长度 = " + textLength);
String strEllipsizeMark = "...";
// 展开控件需要预留的长度,预留宽度:"..." + 展开布局与文本间距 +图标长度 + 展开文本长度
float reservedWidth = mSpaceMargin + textPaint.measureText(strEllipsizeMark) + getExpandLayoutReservedWidth();
Log.d(TAG, "需要预留的长度 = " + reservedWidth);
int correctEndPos = endPos;
if (reservedWidth + textLength > lineWidth) {
// 空间不够,需要按比例截取最后一行的字符串,以确保展示的最后一行文本不会与可展开布局重叠
float exceedSpace = reservedWidth + textLength - lineWidth;
if (textLength != 0) {
correctEndPos = endPos - (int) ((exceedSpace / textLength) * 1.0f * (endPos - startPos));
}
}
Log.d(TAG, "correctEndPos = " + correctEndPos);
String ellipsizeStr = mOriginContentStr.substring(0, correctEndPos);
mEllipsizeStr = removeEndLineBreak(ellipsizeStr) + strEllipsizeMark;
}
2、在文本展开时,需要也根据每行文本显示的最大测量宽度lineWidth、展开时最后一行显示的文本宽度textLength、展开/收缩提示布局宽度expandTipLayoutWidth来综合考虑,有如下两种情况:
1)文字宽度+展开/收缩提示布局的宽度 <= 一行最大展示宽度,可以正常显示,无需特殊处理
2)文字宽度+展开/收缩提示布局的宽度 > 一行最大展示宽度,则需要换行展示可展开/收缩提示按钮及文字
如下代码所示,具体完整代码请参见项目代码:
/**
* 处理最后一行文本
*
* @param staticLayout
* @param lineWidth
*/
private void handleLastLine(StaticLayout staticLayout, int lineWidth) {
if (staticLayout == null) {
return;
}
int lineCount = staticLayout.getLineCount();
if (lineCount < 1) {
return;
}
int startPos = staticLayout.getLineStart(lineCount - 1);
int endPos = staticLayout.getLineEnd(lineCount - 1);
Log.d(TAG, "最后一行 startPos = " + startPos);
Log.d(TAG, "最后一行 endPos = " + endPos);
// 修正,防止取子串越界
if (startPos < 0) {
startPos = 0;
}
if (endPos > mOriginContentStr.length()) {
endPos = mOriginContentStr.length();
}
if (startPos > endPos) {
startPos = endPos;
}
String lastLineContent = mOriginContentStr.substring(startPos, endPos);
Log.d(TAG, "最后一行 内容 = " + lastLineContent);
float textLength = 0f;
TextPaint textPaint = mTvContent.getPaint();
if (lastLineContent != null) {
textLength = textPaint.measureText(lastLineContent);
}
Log.d(TAG, "最后一行 文本长度 = " + textLength);
float reservedWidth = getExpandLayoutReservedWidth();
if (textLength + reservedWidth > lineWidth) {
//文字宽度+展开布局的宽度 > 一行最大展示宽度
//换行展示“收起”按钮及文字
mOriginContentStr += "\n";
}
}
ExpandLayout在普通布局以及ListView或RecyclerView中使用方法一致,使用也很简单:
1、在布局xml中声明ExpandLayout,如下所示:
2、获取ExpandLayout实例,并设置需要显示的文本:
下面介绍文本文本设置,其他自定义属性的配置和设置请参考实例代码
文本设置使用有如下两种方式:
1)普通设置,直接通过调用ExpandLayout的setContent(String)方法,直接将要显示的文本设置即可:
mExpandLayout = findViewById(R.id.my_expand_layout);
String contentStr = "我是正常的全中文文字,可以点击我展开查看更多或收起,我是图标+文字的默认模式";
mExpandLayout.setContent(contentStr);
2)带展开和收缩状态的回调监听的文本设置
有一些业务可能会需要监听文本展开或收缩时做一些其它操作,如果需要监听展开和收缩状态,直接调用ExpandLayout的setContent(String,OnExpandStateChangeListener),并重写OnExpandStateChangeListener相应的状态回调方法即可:
mExpandLayout = findViewById(R.id.my_expand_layout);
String contentStr = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
mExpandLayout.setContent(contentStr, new ExpandLayout.OnExpandStateChangeListener() {
@Override
public void onExpand() {
Toast.makeText(mActivity, "onExpand", Toast.LENGTH_SHORT).show();
}
@Override
public void onCollapse() {
Toast.makeText(mActivity, "onCollapse", Toast.LENGTH_SHORT).show();
}
});
所有代码已提交到本人GitHub项目,有兴趣的朋友可以参考本人GitHub项目代码和参考使用例程,希望能给大家带有更多的参考价值,链接请见https://github.com/oukanggui/WidgetLibrary/blob/master/widget/src/main/java/com/baymax/widget/ExpandLayout.java
欢迎大家一起完善该控件,有问题或建议欢迎在issue提出。欢迎大家Star!