美文网首页各种viewAndroid开发精选定义控件
教你实现一个具备展开折叠功能的TextView

教你实现一个具备展开折叠功能的TextView

作者: 皮球二二 | 来源:发表于2016-09-13 22:54 被阅读12515次

可折叠的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;
}

来看看最终效果

列表中的使用

相关文章

网友评论

  • 我一定会学会:请问一下,如何取消动画
    我一定会学会:我发现一个bug,在RecycleView里面的复用问题,item0你按展开,然后上下滑,后再让item0显示,这个时候你按下item0的收起,就会发现item0所有文字都被收起了。而不是之前默认的2行。
    我一定会学会:@r17171709 我是菜鸟,请问要注释哪个地方,里面没有取消动画对应的方法吗?:joy:
    皮球二二:@我一定会学会 属性动画部分去掉
  • df1616200c6d:请问一下动画效果可以使用属性动画实现么
    雁南飞止于此:没啥问题啊。本质都一样的。属性动画更好。
    可以参考我的github:
    https://github.com/pingia/TextExpandOrFoldDemo/tree/master
  • f66a190500cc:楼主知道排版混乱这个怎么解决吗?
    皮球二二: @我们小区车队队长 排版混乱可以参考adjustTextview这个项目
  • BinZzz:我想 android:ellipsize="end" 这个值会生效应该怎么做
    关闭的时候 TextView最后展示...
    打开的时候 没有...

    现在是第一次关闭有... 打开没问题 再关闭就没...了
  • 118d1396fea4:这个有个问题 文字展开超出屏幕,收起会出问题
    皮球二二: @一生所愛_537d 这个也没有测试过。。。
  • 早起的鸟儿写代码:发现一个bug,会引起行高度错乱。
    解决方案:
    ExpandableTextView的第157行左右。
    onMeasure方法中的
    if (isCollapsed) {
    id_source_textview.setLines(maxExpandLines);
    }
    改为
    if (isCollapsed) {
    // setLines会引发 内容textview高度错乱
    //id_source_textview.setLines(maxExpandLines);
    id_source_textview.setMaxLines(maxExpandLines);
    }
    皮球二二: @早起的鸟儿oo 666
  • 早起的鸟儿写代码:Manabu-GT的ExpandableTextView 有个bug,滑动到边界的时候 展开后再合上,内容就为空了,而且高度有时候也会有问题。你的比他的好用 O(∩_∩)O 加油
  • 一梦付浮生:感觉写的复杂了
  • 李是猴子搬来的救兵:有一个问题就是,如果我将一个textview和一个expandabletextview同时放到一个linearlayout中,horizontal的布局,从左到右,会发现expandabletextview就失效了,能帮忙看看这个是什么问题么?
  • 7103937fc753:我把展开和收起换成俩张图片 为什么点击“”展开“”的时候,会闪一下
    7103937fc753:解决了。之前把id_source_textview的宽设置成了match_parent,后来设置成wrap_concent就好了
  • timshinlee:很详细,感谢作者分享
  • 2015哈哈哈:你好作者,项目中用了这个控件在listview中比如默认显示5行如果只有一行文字,也显示5行的区域;如果支持CharSequence也就是用SpannableString显示文本信息的话折叠展开的是上半部分而不是下半部分。请问您能修复这两个问题吗
    皮球二二: @伟伟and莉莉 可以
    2015哈哈哈:@r17171709 晚上有空吗,我把这部分从项目中抽离出来稍后发给你,你看这样子可以吗
    皮球二二: @伟伟and莉莉 能提供你的代码下载地址吗?我直接在那个上面修改看看
  • 小辉灰:github上的代码为啥下载不了,没有下载功能
    皮球二二: @clailan 可以clone
  • 一花一朝:发现一个问题。当此控件放在listView 中,第一个item的内容是大于默认高度(假如10行)时,其他的item内容的高度也是10行,但是其他item的数据没有10行的高度,
    System_O:遇到了同样的问题,也暂时没找到解决办法
    8a3cd902eedc:遇到了同样的问题,也暂时没找到解决办法
    皮球二二:@a4667661fb36 你是初始化时候就不对了吗?
  • 1e9498ad494f:楼主不能把全部代码贴出来,只贴出一部分,对于我这种小白根本不知道片段代码是放到哪里去的,
    皮球二二: @1e9498ad494f 已经有github地址了啊
  • RabbitL:前两天在GitHub上找到了一个Demo 但是只是大概看了一下,文章写的很详细,厉害呢~
  • 皮球侧飞:不断的requestLayout()会不会一性能问题
    皮球二二: @_Albert 不会不断的,因为getview的缘故,所以每次出现一个新item或者复用只会调用一次
  • 呆T_T呆:太棒了:clap::clap:,多谢作者的分享
  • 迷糊娃娃i:喜欢,支持你
  • 言葉之:棒棒哒
  • 挨得乐:喜欢
  • 愛餅才會丫:支持二二
    皮球二二:@愛餅才會丫 谢谢
  • 陆地蛟龙:支持岳父。
    皮球二二:@胡髭蛤蟆 。。。
  • Pan_大宝:马克
  • 小人物灌篮:收藏了
  • xiasuhuei321:666,那天我朋友还问我这类东西怎么做,自己太菜了没做过……

本文标题:教你实现一个具备展开折叠功能的TextView

本文链接:https://www.haomeiwen.com/subject/ihtmettx.html