教你实现一个具备展开折叠功能的TextView
可折叠的textview是一个很常见的功能,相信大家都在微信朋友圈体验过这种场景:朋友发的笑话都只有半截,下面是一片白色,你要展开全文之后才能知道最后结果。
其实这也不是什么高大上的东西,网上也有现成的例子,但是使用起来还是得稍微调整一下,最牛逼的应该就是Manabu-GT的ExpandableTextView。本篇文章将对该源码进行分析学习,最后自己来撸一发
项目已经发布在github上

我们先实现一个基本功能,能点击收放就行了,其他效果先不管
定义相关基本属性
仔细看下上图,其实这个自定义的控件也就由两部分组成,一个是正常的显示文本部分,另外一个是收放的按钮,因此我们可以通过组合布局的形式来实现
首先写死id,这是因为我们要在组合布局里面操作这2个TextView,所以最简单的途径就是直接拿到这2个对象进行使用
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 原始textview -->
<item name="id_source_textview" type="id"></item>
<!-- 收起展开按钮textview -->
<item name="id_expand_textview" type="id"></item>
</resources>
然后是自定义属性的设计
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ExpandableTextViewAttr">
<!-- 允许显示最大行数 -->
<attr name="maxExpandLines" format="integer"></attr>
</declare-styleable>
</resources>
剩下就是很传统的初始化
public class ExpandableTextView extends LinearLayout {
TextView id_source_textview;
TextView id_expand_textview;
public ExpandableTextView(Context context) {
this(context, null);
}
public ExpandableTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
setOrientation(VERTICAL);
TypedArray array=context.obtainStyledAttributes(attrs, R.styleable.ExpandableTextViewAttr);
maxExpandLines=array.getInteger(R.styleable.ExpandableTextViewAttr_maxExpandLines, 3);
array.recycle();
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
id_source_textview= (TextView) findViewById(R.id.id_source_textview);
id_expand_textview= (TextView) findViewById(R.id.id_expand_textview);
id_expand_textview.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
}
});
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
OK,大家都会的说完了,下面开始分逻辑一一说明
测量
onMeasure想必不用多说了,他是负责对ViewGroup进行测量,用来把握整体ViewGroup的大小。那么我们这里就可以利用这个方法来对组合布局收放的大小进行控制
public void setText(String text) {
isChange=true;
id_source_textview.setText(text);
}
首先,如果你在两次setText之间没有发生文本变化,或者这个组合布局本身都不显示,那么我们果断的终止计算操作,因为这个计算过程是没有意义的
//如果隐藏控件或者textview的值没有发生改变,那么不进行测量
if (getVisibility()==GONE || !isChange) {
return;
}
isChange=false;
完成上述的判断之后,我们就开始真正的去实现收起一个textview的功能了
//初始化默认状态,即正常显示文本
id_expand_textview.setVisibility(GONE);
id_source_textview.setMaxLines(Integer.MAX_VALUE);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
如果文本行数不满足收起展开的最小行数,那么终止,而以初始的状态展现在我们的眼前
//如果本身没有达到收起展开的限定要求,则不进行处理
if (id_source_textview.getLineCount()<=maxExpandLines) {
return;
}
默认我们是设置成收起状态的,在收起状态时,我们设置当前行数为最大可显示行数,并且按钮显示出来
if (isCollapsed) {
id_source_textview.setMaxLines(maxExpandLines);
}
id_expand_textview.setVisibility(VISIBLE);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
测量部分就结束了。记住这里又一个关键点,每次UI的变化,都需要我们去重新测量,不然最终获取出来的数据就会有问题
点击效果
无非就是收放的切换
id_expand_textview.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
isCollapsed=!isCollapsed;
if (isCollapsed) {
id_expand_textview.setText("展开");
}
else {
id_expand_textview.setText("收起");
}
//不带动画的处理方式
isChange=true;
requestLayout();
}
});
使用
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.renyu.expandabletextview.myview.ExpandableTextView
android:id="@+id/expandable_text"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@id/id_source_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@id/id_expand_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="展开" />
</com.renyu.expandabletextview.myview.ExpandableTextView>
</RelativeLayout>
expandable_text= (ExpandableTextView) findViewById(R.id.expandable_text);
expandable_text.setText("挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的" +
"挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的" +
"挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的" +
"挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的" +
"挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的" +
"挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的挨打的");

动画效果
基本功能我们是完成了,但是不能仅仅满足这一点点。通过点击进行过渡切换,这样的效果体验上肯定会更好一些
继续添加我们的自定义属性,这里我们添加的是动画执行时间
<!-- 动画执行时间 -->
<attr name="duration" format="integer"></attr>
默认为500ms
duration=array.getInteger(R.styleable.ExpandableTextViewAttr_duration, 500);
动画参数的初始化
既然涉及到高度的变化,那么我们就必须要知道收起与展开2个不同状态下的高度值,因为动画变化区间就在这2个值范围内。展开状态下的高度就是设置maxLines为Integer.Max时候ViewGroup的高度,收起状态下的高度就是设置了maxExpandLines值之后的ViewGroup的高度
获取文本的高度
//初始化高度赋值,为后续动画事件准备数据
realTextViewHeigt=getRealTextViewHeight(id_source_textview);
private int getRealTextViewHeight(TextView textView) {
//getLineTop返回值是一个根据行数而形成等差序列,如果参数为行数,则值即为文本的高度
int textHeight=textView.getLayout().getLineTop(textView.getLineCount());
return textHeight+textView.getCompoundPaddingBottom()+textView.getCompoundPaddingTop();
}
收起之后的高度,这个需要等ViewGroup渲染完成之后才能真正获取到。这里lastHeight指的是总高度减去文本部分的高度,也就是收放按钮所占区域高度
if (isCollapsed) {
id_source_textview.post(new Runnable() {
@Override
public void run() {
lastHeight=getHeight()-id_source_textview.getHeight();
collapsedHeight=getMeasuredHeight();
}
});
}
那么很明显,ViewGroup完全展开后的高度为realTextViewHeigt+lastHeight,完全收起时候的高度为collapsedHeight。整个收放的过程由于没有文字的变化,所以并没有进行相关计算,只是单纯的修改高度而已
private class ExpandCollapseAnimation extends Animation {
int startValue=0;
int endValue=0;
public ExpandCollapseAnimation(int startValue, int endValue) {
setDuration(duration);
this.startValue=startValue;
this.endValue=endValue;
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
super.applyTransformation(interpolatedTime, t);
int height=(int) ((endValue-startValue)*interpolatedTime+startValue);
id_source_textview.setMaxHeight(height-lastHeight);
ExpandableTextView.this.getLayoutParams().height=height;
ExpandableTextView.this.requestLayout();
}
@Override
public boolean willChangeBounds() {
return true;
}
}
点击切换
点击切换部分主要是动画的操作
id_expand_textview.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
ExpandCollapseAnimation animation;
isCollapsed=!isCollapsed;
if (isCollapsed) {
id_expand_textview.setText("展开");
animation=new ExpandCollapseAnimation(getHeight(), collapsedHeight);
}
else {
id_expand_textview.setText("收起");
animation=new ExpandCollapseAnimation(getHeight(), realTextViewHeigt+lastHeight);
}
animation.setFillAfter(true);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
isAnimate=true;
}
@Override
public void onAnimationEnd(Animation animation) {
clearAnimation();
isAnimate=false;
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
clearAnimation();
startAnimation(animation);
}
});
}
当然,为了在动画的执行过程中防止再次点击到切换按钮,可以这样直接拦截点击事件
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//执行动画的过程中屏蔽事件
return isAnimate;
}

复用问题
刚才看到的都是普通场景下的使用,这个一般不会有什么问题。但是一旦牵扯到列表复用,问题就又乱七八糟的出现了,比如之前展开的收起来了,但是底部文字没有变化之类的。其实解决这个问题也很简单,只要我们每次记录当前收放状态并且每次复用的时候重新去设置一遍就行了
来看一些列表的实体bean,主要就是文字与状态2个变量
public class DataBean {
String text;
boolean isCollapsed=true;
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public boolean isCollapsed() {
return isCollapsed;
}
public void setCollapsed(boolean collapsed) {
isCollapsed = collapsed;
}
}
对刚才的ViewGroup添加一个状态变化的回调接口,这样在点击部分可以通过回调传给adapter中的数据源,这里就不再对setOnClickListener进行赘述了
public interface OnExpandStateChangeListener {
void onExpandStateChanged(boolean isExpanded);
}
最后是一个关键的地方,我们扩展一下之前的setText()方法。我们需要将收放状态再次带入,同时还有一个关键的地方,将高度重置
public void setText(String text, boolean isCollapsed) {
this.isCollapsed=isCollapsed;
if (isCollapsed) {
id_expand_textview.setText("展开");
}
else {
id_expand_textview.setText("收起");
}
clearAnimation();
setText(text);
getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
}
来看看最终效果
