Android - Expandable TextView with Animation

I have a TextView which firstly shows a small portion of a long text.

The user can press a "see more" button to expand the TextView and see the rest of that text.

Making tests, I can reach that by simply interchange the value of TextView.setMaxLines between 4 for collapsing and Integer.MAX_VALUE for expanding.

Now, I would like that this behavior would be accompanied by an animation. I know that in this question one solution is almost done, but I tried to implement it and I have no success.

Can someone help me with this?


You can check my blog post on ExpandableTexTView:

The idea is, initially the TextView will show a small portion of a long text and when it is clicked, it will show the rest of the text.

So here is the code that how I solved it.

package com.rokonoid.widget;

import android.content.Context;
import android.content.res.TypedArray;
import android.text.SpannableStringBuilder;
import android.util.AttributeSet;
import android.view.View;
import android.widget.TextView;
/**
 * User: Bazlur Rahman Rokon
 * Date: 9/7/13 - 3:33 AM
 */
public class ExpandableTextView extends TextView {
    private static final int DEFAULT_TRIM_LENGTH = 200;
    private static final String ELLIPSIS = ".....";

    private CharSequence originalText;
    private CharSequence trimmedText;
    private BufferType bufferType;
    private boolean trim = true;
    private int trimLength;

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

    public ExpandableTextView(Context context, AttributeSet attrs) {
        super(context, attrs);

        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ExpandableTextView);
        this.trimLength = typedArray.getInt(R.styleable.ExpandableTextView_trimLength, DEFAULT_TRIM_LENGTH);
        typedArray.recycle();

        setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                trim = !trim;
                setText();
                requestFocusFromTouch();
            }
        });
    }

    private void setText() {
        super.setText(getDisplayableText(), bufferType);
    }

    private CharSequence getDisplayableText() {
        return trim ? trimmedText : originalText;
    }

    @Override
    public void setText(CharSequence text, BufferType type) {
        originalText = text;
        trimmedText = getTrimmedText(text);
        bufferType = type;
        setText();
    }

    private CharSequence getTrimmedText(CharSequence text) {
        if (originalText != null && originalText.length() > trimLength) {
            return new SpannableStringBuilder(originalText, 0, trimLength + 1).append(ELLIPSIS);
        } else {
            return originalText;
        }
    }

    public CharSequence getOriginalText() {
        return originalText;
    }

    public void setTrimLength(int trimLength) {
        this.trimLength = trimLength;
        trimmedText = getTrimmedText(originalText);
        setText();
    }

    public int getTrimLength() {
        return trimLength;
    }
}

And add the following line in your attr.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ExpandableTextView">
        <attr name="trimLength" format="integer"/>
    </declare-styleable>
</resources>

Put the following in your main.xml

<?xml version="1.0" encoding="utf-8"?>
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:orientation="vertical"
     android:layout_width="fill_parent"
     android:layout_height="fill_parent">

     <com.rokonoid.widget.ExpandableTextView
         android:id="@+id/lorem_ipsum"
         android:layout_width="fill_parent"
         android:layout_height="wrap_content"/>

 </LinearLayout>

And test your activity

package com.rokonoid.widget;

import android.app.Activity;
import android.os.Bundle;

public class MyActivity extends Activity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        String yourText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
                "Ut volutpat interdum interdum. Nulla laoreet lacus diam, vitae " +
                "sodales sapien commodo faucibus. Vestibulum et feugiat enim. Donec " +
                "semper mi et euismod tempor. Sed sodales eleifend mi id varius. Nam " +
                "et ornare enim, sit amet gravida sapien. Quisque gravida et enim vel " +
                "volutpat. Vivamus egestas ut felis a blandit. Vivamus fringilla " +
                "dignissim mollis. Maecenas imperdiet interdum hendrerit. Aliquam" +
                " dictum hendrerit ultrices. Ut vitae vestibulum dolor. Donec auctor ante" +
                " eget libero molestie porta. Nam tempor fringilla ultricies. Nam sem " +
                "lectus, feugiat eget ullamcorper vitae, ornare et sem. Fusce dapibus ipsum" +
                " sed laoreet suscipit. ";

        ExpandableTextView expandableTextView = (ExpandableTextView) findViewById(R.id.lorem_ipsum);
        expandableTextView.setText(yourText);
    }
}

Reference: Android – Expandable TextView


Use an ObjectAnimator.

ObjectAnimator animation = ObjectAnimator.ofInt(yourTextView, "maxLines", tv.getLineCount());
animation.setDuration(200).start();

This will fully expand your TextView over 200 milliseconds. You can replace tv.getLineCount() with however many lines of text you wish to collapse it back down.

----Update----

Here are some convenience methods you can drop in:

private void expandTextView(TextView tv){
    ObjectAnimator animation = ObjectAnimator.ofInt(tv, "maxLines", tv.getLineCount());
    animation.setDuration(200).start();
}

private void collapseTextView(TextView tv, int numLines){
    ObjectAnimator animation = ObjectAnimator.ofInt(tv, "maxLines", numLines);
    animation.setDuration(200).start();
}

If you're on API 16+, you can use textView.getMaxLines() to easily determine if your textView has been expanded or not.

private void cycleTextViewExpansion(TextView tv){
    int collapsedMaxLines = 3;
    ObjectAnimator animation = ObjectAnimator.ofInt(tv, "maxLines", 
        tv.getMaxLines() == collapsedMaxLines? tv.getLineCount() : collapsedMaxLines);
    animation.setDuration(200).start();
}

Notes:

If maxLines has not been set, or you've set the height of your textView in pixels, you can get an ArrayIndexOutOfBounds exception.

The above examples always take 200ms, whether they expand by 3 lines or 400. If you want a consistent rate of expansion, you can do something like this:

int duration = (textView.getLineCount() - collapsedMaxLines) * 10;

I created an open-source library for this, because I wasn’t satisfied with the other solutions I found on the internet. I’ve put the thing on GitHub, and it’s free to use by anyone.

public class ExpandableTextView extends TextView
{
    // copy off TextView.LINES
    private static final int MAXMODE_LINES = 1;

    private OnExpandListener onExpandListener;
    private TimeInterpolator expandInterpolator;
    private TimeInterpolator collapseInterpolator;

    private final int maxLines;
    private long animationDuration;
    private boolean animating;
    private boolean expanded;
    private int originalHeight;

    public ExpandableTextView(final Context context)
    {
        this(context, null);
    }

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

    public ExpandableTextView(final Context context, final AttributeSet attrs, final int defStyle)
    {
        super(context, attrs, defStyle);

        // read attributes
        final TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.ExpandableTextView, defStyle, 0);
        this.animationDuration = attributes.getInt(R.styleable.ExpandableTextView_animation_duration, BuildConfig.DEFAULT_ANIMATION_DURATION);
        attributes.recycle();

        // keep the original value of maxLines
        this.maxLines = this.getMaxLines();

        // create default interpolators
        this.expandInterpolator = new AccelerateDecelerateInterpolator();
        this.collapseInterpolator = new AccelerateDecelerateInterpolator();
    }

    @Override
    public int getMaxLines()
    {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
        {
            return super.getMaxLines();
        }

        try
        {
            final Field mMaxMode = TextView.class.getField("mMaxMode");
            mMaxMode.setAccessible(true);
            final Field mMaximum = TextView.class.getField("mMaximum");
            mMaximum.setAccessible(true);

            final int mMaxModeValue = (int) mMaxMode.get(this);
            final int mMaximumValue = (int) mMaximum.get(this);

            return mMaxModeValue == MAXMODE_LINES ? mMaximumValue : -1;
        }
        catch (final Exception e)
        {
           return -1;
        }
    }

    /**
     * Toggle the expanded state of this {@link ExpandableTextView}.
     * @return true if toggled, false otherwise.
     */
    public boolean toggle()
    {
        if (this.expanded)
        {
            return this.collapse();
        }

        return this.expand();
    }

    /**
     * Expand this {@link ExpandableTextView}.
     * @return true if expanded, false otherwise.
     */
    public boolean expand()
    {
        if (!this.expanded && !this.animating && this.maxLines >= 0)
        {
            this.animating = true;

            // notify listener
            if (this.onExpandListener != null)
            {
                this.onExpandListener.onExpand(this);
            }

            // get original height
            this.measure
            (
                MeasureSpec.makeMeasureSpec(this.getMeasuredWidth(), MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            );

            this.originalHeight = this.getMeasuredHeight();

            // set maxLines to MAX Integer
            this.setMaxLines(Integer.MAX_VALUE);

            // get new height
            this.measure
            (
                MeasureSpec.makeMeasureSpec(this.getMeasuredWidth(), MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
            );

            final int fullHeight = this.getMeasuredHeight();

            final ValueAnimator valueAnimator = ValueAnimator.ofInt(this.originalHeight, fullHeight);
            valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener()
            {
                @Override
                public void onAnimationUpdate(final ValueAnimator animation)
                {
                    final ViewGroup.LayoutParams layoutParams = ExpandableTextView.this.getLayoutParams();
                    layoutParams.height = (int) animation.getAnimatedValue();
                    ExpandableTextView.this.setLayoutParams(layoutParams);
                }
            });
            valueAnimator.addListener(new AnimatorListenerAdapter()
            {
                @Override
                public void onAnimationEnd(final Animator animation)
                {
                    ExpandableTextView.this.expanded = true;
                    ExpandableTextView.this.animating = false;
                }
            });

            // set interpolator
            valueAnimator.setInterpolator(this.expandInterpolator);

            // start the animation
            valueAnimator
                .setDuration(this.animationDuration)
                .start();

            return true;
        }

        return false;
    }

    /**
     * Collapse this {@link TextView}.
     * @return true if collapsed, false otherwise.
     */
    public boolean collapse()
    {
        if (this.expanded && !this.animating && this.maxLines >= 0)
        {
            this.animating = true;

            // notify listener
            if (this.onExpandListener != null)
            {
                this.onExpandListener.onCollapse(this);
            }

            // get new height
            final int fullHeight = this.getMeasuredHeight();

            final ValueAnimator valueAnimator = ValueAnimator.ofInt(fullHeight, this.originalHeight);
            valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener()
            {
                @Override
                public void onAnimationUpdate(final ValueAnimator animation)
                {
                    final ViewGroup.LayoutParams layoutParams = ExpandableTextView.this.getLayoutParams();
                    layoutParams.height = (int) animation.getAnimatedValue();
                    ExpandableTextView.this.setLayoutParams(layoutParams);
                }
            });
            valueAnimator.addListener(new AnimatorListenerAdapter()
            {
                @Override
                public void onAnimationEnd(final Animator animation)
                {
                    // set maxLines to original value
                    ExpandableTextView.this.setMaxLines(ExpandableTextView.this.maxLines);

                    ExpandableTextView.this.expanded = false;
                    ExpandableTextView.this.animating = false;
                }
            });

            // set interpolator
            valueAnimator.setInterpolator(this.collapseInterpolator);

            // start the animation
            valueAnimator
                .setDuration(this.animationDuration)
                .start();

            return true;
        }

        return false;
    }

    /**
     * Sets the duration of the expand / collapse animation.
     * @param animationDuration duration in milliseconds.
     */
    public void setAnimationDuration(final long animationDuration)
    {
        this.animationDuration = animationDuration;
    }

    /**
     * Sets a listener which receives updates about this {@link ExpandableTextView}.
     * @param onExpandListener the listener.
     */
    public void setOnExpandListener(final OnExpandListener onExpandListener)
    {
        this.onExpandListener = onExpandListener;
    }

    /**
     * Returns the {@link OnExpandListener}.
     * @return the listener.
     */
    public OnExpandListener getOnExpandListener()
    {
        return onExpandListener;
    }

    /**
     * Sets a {@link TimeInterpolator} for expanding and collapsing.
     * @param interpolator the interpolator
     */
    public void setInterpolator(final TimeInterpolator interpolator)
    {
        this.expandInterpolator = interpolator;
        this.collapseInterpolator = interpolator;
    }

    /**
     * Sets a {@link TimeInterpolator} for expanding.
     * @param expandInterpolator the interpolator
     */
    public void setExpandInterpolator(final TimeInterpolator expandInterpolator)
    {
        this.expandInterpolator = expandInterpolator;
    }

    /**
     * Returns the current {@link TimeInterpolator} for expanding.
     * @return the current interpolator, null by default.
     */
    public TimeInterpolator getExpandInterpolator()
    {
        return this.expandInterpolator;
    }

    /**
     * Sets a {@link TimeInterpolator} for collpasing.
     * @param collapseInterpolator the interpolator
     */
    public void setCollapseInterpolator(final TimeInterpolator collapseInterpolator)
    {
        this.collapseInterpolator = collapseInterpolator;
    }

    /**
     * Returns the current {@link TimeInterpolator} for collapsing.
     * @return the current interpolator, null by default.
     */
    public TimeInterpolator getCollapseInterpolator()
    {
        return this.collapseInterpolator;
    }

    /**
     * Is this {@link ExpandableTextView} expanded or not?
     * @return true if expanded, false if collapsed.
     */
    public boolean isExpanded()
    {
        return this.expanded;
    }

    public interface OnExpandListener
    {
        void onExpand(ExpandableTextView view);
        void onCollapse(ExpandableTextView view);
    }
}

Using the ExpandableTextView is very easy, it’s just a regular TextView with some extra functionality added to it. By defining the android:maxLines attribute, you can set the default number of lines for the TextView collapsed state.

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <at.blogc.android.views.ExpandableTextView
        android:id="@+id/expandableTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/lorem_ipsum"
        android:maxLines="5"
        android:ellipsize="end"
        app:animation_duration="1000"/>

    <!-- Optional parameter animation_duration: sets the duration of the expand animation -->

    <Button
        android:id="@+id/button_toggle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/expand"/>

</LinearLayout>

In your Activity or Fragment:

    final ExpandableTextView expandableTextView = (ExpandableTextView) this.findViewById(R.id.expandableTextView);
    final Button buttonToggle = (Button) this.findViewById(R.id.button_toggle);

    // set animation duration via code, but preferable in your layout files by using the animation_duration attribute
    expandableTextView.setAnimationDuration(1000L);

// set interpolators for both expanding and collapsing animations
expandableTextView.setInterpolator(new OvershootInterpolator());

// or set them separately
expandableTextView.setExpandInterpolator(new OvershootInterpolator());
expandableTextView.setCollapseInterpolator(new OvershootInterpolator());


    // toggle the ExpandableTextView
    buttonToggle.setOnClickListener(new View.OnClickListener()
    {
        @Override
        public void onClick(final View v)
        {
            expandableTextView.toggle();
            buttonToggle.setText(expandableTextView.isExpanded() ? R.string.collapse : R.string.expand);
        }
    });

    // but, you can also do the checks yourself
    buttonToggle.setOnClickListener(new View.OnClickListener()
    {
        @Override
        public void onClick(final View v)
        {
            if (expandableTextView.isExpanded())
            {
                expandableTextView.collapse();
                buttonToggle.setText(R.string.expand);
            }
            else
            {
                expandableTextView.expand();
                buttonToggle.setText(R.string.collapse);
            }
        }
    });

    // listen for expand / collapse events
    expandableTextView.setOnExpandListener(new ExpandableTextView.OnExpandListener()
    {
        @Override
        public void onExpand(final ExpandableTextView view)
        {
            Log.d(TAG, "ExpandableTextView expanded");
        }

        @Override
        public void onCollapse(final ExpandableTextView view)
        {
            Log.d(TAG, "ExpandableTextView collapsed");
        }
    });

You can easily add this library as a gradle dependency to your Android project. Take a look at the project on Github for further instructions:

https://github.com/Blogcat/Android-ExpandableTextView


Smooth expanding (using heigh & ObjectAnimator)
FYI: requires API 11

public static void expandCollapsedByMaxLines(@NonNull final TextView text) {
    final int height = text.getMeasuredHeight();
    text.setHeight(height);
    text.setMaxLines(Integer.MAX_VALUE); //expand fully
    text.measure(View.MeasureSpec.makeMeasureSpec(text.getMeasuredWidth(), View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(ViewGroup.LayoutParams.WRAP_CONTENT, View.MeasureSpec.UNSPECIFIED));
    final int newHeight = text.getMeasuredHeight();
    ObjectAnimator animation = ObjectAnimator.ofInt(text, "height", height, newHeight);
    animation.setDuration(250).start();
}

P.S. I assume TextView limited by maxLines.
P.S.S. Thanks Amagi82 for ObjectAnimator example


If you want to do it based on the number of lines, here's a way to do it:

(Gist of full code)

/**
 * Ellipsize the text when the lines of text exceeds the value provided by {@link #makeExpandable} methods.
 * Appends {@link #MORE} or {@link #LESS} as needed.
 * TODO: add animation
 * Created by vedant on 3/10/15.
 */
public class ExpandableTextView extends TextView {
    private static final String TAG = "ExpandableTextView";
    private static final String ELLIPSIZE = "... ";
    private static final String MORE = "more";
    private static final String LESS = "less";

    private String mFullText;
    private int mMaxLines;

    //...constructors...

    public void makeExpandable(String fullText, int maxLines) {
        mFullText =fullText;
        mMaxLines = maxLines;
        ViewTreeObserver vto = getViewTreeObserver();
        vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                ViewTreeObserver obs = getViewTreeObserver();
                obs.removeOnGlobalLayoutListener(this);
                if (getLineCount() <= maxLines) {
                    setText(mFullText);
                } else {
                    setMovementMethod(LinkMovementMethod.getInstance());
                    showLess();
                }
            }
        });
    }

    /**
     * truncate text and append a clickable {@link #MORE}
     */
    private void showLess() {
        int lineEndIndex = getLayout().getLineEnd(mMaxLines - 1);
        String newText = mFullText.substring(0, lineEndIndex - (ELLIPSIZE.length() + MORE.length() + 1))
                + ELLIPSIZE + MORE;
        SpannableStringBuilder builder = new SpannableStringBuilder(newText);
        builder.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                showMore();
            }
        }, newText.length() - MORE.length(), newText.length(), 0);
        setText(builder, BufferType.SPANNABLE);
    }

    /**
     * show full text and append a clickable {@link #LESS}
     */
    private void showMore() {
        // create a text like subText + ELLIPSIZE + MORE
        SpannableStringBuilder builder = new SpannableStringBuilder(mFullText + LESS);
        builder.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                showLess();
            }
        }, builder.length() - LESS.length(), builder.length(), 0);
        setText(builder, BufferType.SPANNABLE);
    }
}