62

Is there an easy way to create expandable/collapsible blocks like seen in official market app?

Screenshot of Market app, when you click on "More" button, the description section expands with animation:

enter image description here

I know of SlidingDrawer but it doesn't seem to be suited for stuff like this--it's supposed to be put in overlay, and doesn't support half-open states.

Update:

Here's my half-working solution. It's a custom widget that extends LinearLayout. It kind-of works, but doesn't handle edge cases well, like content height smaller than collapsedHeight parameter. I'm sure with enough staring, digging in code and experimenting the quirks could be fixed. Was hoping to avoid doing that, and save some time by using a ready-made official or 3rd party solution. Anyway, here it is, code:

package com.example.androidapp.widgets;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.Transformation;
import android.widget.LinearLayout;

import com.example.androidapp.R;

public class ExpandablePanel extends LinearLayout {

    private final int mHandleId;
    private final int mContentId;

    private View mHandle;
    private View mContent;

    private boolean mExpanded = true;
    private int mCollapsedHeight = 0;
    private int mContentHeight = 0;

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

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

        TypedArray a = context.obtainStyledAttributes(attrs,
            R.styleable.ExpandablePanel, 0, 0);

        // How high the content should be in "collapsed" state
        mCollapsedHeight = (int) a.getDimension(
            R.styleable.ExpandablePanel_collapsedHeight, 0.0f);

        int handleId = a.getResourceId(R.styleable.ExpandablePanel_handle, 0);
        if (handleId == 0) {
            throw new IllegalArgumentException(
                "The handle attribute is required and must refer "
                    + "to a valid child.");
        }

        int contentId = a.getResourceId(R.styleable.ExpandablePanel_content, 0);
        if (contentId == 0) {
            throw new IllegalArgumentException(
                "The content attribute is required and must refer "
                    + "to a valid child.");
        }

        mHandleId = handleId;
        mContentId = contentId;

        a.recycle();
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        mHandle = findViewById(mHandleId);
        if (mHandle == null) {
            throw new IllegalArgumentException(
                "The handle attribute is must refer to an"
                    + " existing child.");
        }

        mContent = findViewById(mContentId);
        if (mContent == null) {
            throw new IllegalArgumentException(
                "The content attribute is must refer to an"
                    + " existing child.");
        }

        mHandle.setOnClickListener(new PanelToggler());
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mContentHeight == 0) {
            // First, measure how high content wants to be
            mContent.measure(widthMeasureSpec, MeasureSpec.UNSPECIFIED);
            mContentHeight = mContent.getMeasuredHeight();
        }

        // Then let the usual thing happen
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    private class PanelToggler implements OnClickListener {
        public void onClick(View v) {
            Animation a;
            if (mExpanded) {
                a = new ExpandAnimation(mContentHeight, mCollapsedHeight);
            } else {
                a = new ExpandAnimation(mCollapsedHeight, mContentHeight);
            }
            a.setDuration(500);
            mContent.startAnimation(a);
            mExpanded = !mExpanded;
        }
    }

    private class ExpandAnimation extends Animation {
        private final int mStartHeight;
        private final int mDeltaHeight;

        public ExpandAnimation(int startHeight, int endHeight) {
            mStartHeight = startHeight;
            mDeltaHeight = endHeight - startHeight;
        }

        @Override
        protected void applyTransformation(float interpolatedTime,
            Transformation t) {
            android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
            lp.height = (int) (mStartHeight + mDeltaHeight * interpolatedTime);
            mContent.setLayoutParams(lp);
        }

        @Override
        public boolean willChangeBounds() {
            // TODO Auto-generated method stub
            return true;
        }
    }
}

Here's res/values/attrs.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <declare-styleable name="ExpandablePanel">
    <attr name="handle" format="reference" />
    <attr name="content" format="reference" />
    <attr name="collapsedHeight" format="dimension" />
  </declare-styleable>
</resources>

And here's how I use it in layout:

<com.example.androidapp.widgets.ExpandablePanel
    android:orientation="vertical"
    android:layout_height="wrap_content"
    android:layout_width="fill_parent"
    example:handle="@+id/expand"
    example:content="@+id/value"
    example:collapsedHeight="50dip">
    <TextView
        android:id="@id/value"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:maxHeight="50dip"
        />
    <Button
        android:id="@id/expand"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="More" />
</com.example.androidapp.widgets.ExpandablePanel>
Pēteris Caune
  • 43,578
  • 6
  • 59
  • 81
  • 1
    Check out http://stackoverflow.com/questions/3695856/android-slidingdrawer-from-top - the accepted answer has some 3rd party library that may be of interest. http://code.google.com/p/android-misc-widgets/source/browse/trunk/android-misc-widgets/src/org/miscwidgets/widget/Panel.java this one in particular. – Codemonkey Mar 02 '11 at 09:51

4 Answers4

56

Thanks very much OP! For anyone interested I took OP's solution and refined it a bit.

  • Handle only displays if there is overflow
  • Added ability to specify animation duration via 'animationDuration' attribute
  • Added ability to attach event listeners that get fired onExpand and onCollapse (this is useful for e.g changing the text of the "More" button to "Less"
  • Collapsed by default
  • Content can be modified programmatically (same with attributes)

Here's the updated code:

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.Transformation;
import android.widget.LinearLayout;

public class ExpandablePanel extends LinearLayout {

    private final int mHandleId;
    private final int mContentId;

    private View mHandle;
    private View mContent;

    private boolean mExpanded = false;
    private int mCollapsedHeight = 0;
    private int mContentHeight = 0;
    private int mAnimationDuration = 0;

    private OnExpandListener mListener;

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

    public ExpandablePanel(Context context, AttributeSet attrs) {
        super(context, attrs);
        mListener = new DefaultOnExpandListener();

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ExpandablePanel, 0, 0);

        // How high the content should be in "collapsed" state
        mCollapsedHeight = (int) a.getDimension(R.styleable.ExpandablePanel_collapsedHeight, 0.0f);

        // How long the animation should take
        mAnimationDuration = a.getInteger(R.styleable.ExpandablePanel_animationDuration, 500);

        int handleId = a.getResourceId(R.styleable.ExpandablePanel_handle, 0);
        if (handleId == 0) {
            throw new IllegalArgumentException(
                "The handle attribute is required and must refer "
                    + "to a valid child.");
        }

        int contentId = a.getResourceId(R.styleable.ExpandablePanel_content, 0);
        if (contentId == 0) {
            throw new IllegalArgumentException("The content attribute is required and must refer to a valid child.");
        }

        mHandleId = handleId;
        mContentId = contentId;

        a.recycle();
    }

    public void setOnExpandListener(OnExpandListener listener) {
        mListener = listener; 
    }

    public void setCollapsedHeight(int collapsedHeight) {
        mCollapsedHeight = collapsedHeight;
    }

    public void setAnimationDuration(int animationDuration) {
        mAnimationDuration = animationDuration;
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        mHandle = findViewById(mHandleId);
        if (mHandle == null) {
            throw new IllegalArgumentException(
                "The handle attribute is must refer to an"
                    + " existing child.");
        }

        mContent = findViewById(mContentId);
        if (mContent == null) {
            throw new IllegalArgumentException(
                "The content attribute must refer to an"
                    + " existing child.");
        }

        android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
        lp.height = mCollapsedHeight;
        mContent.setLayoutParams(lp);

        mHandle.setOnClickListener(new PanelToggler());
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // First, measure how high content wants to be
        mContent.measure(widthMeasureSpec, MeasureSpec.UNSPECIFIED);
        mContentHeight = mContent.getMeasuredHeight();

        if (mContentHeight < mCollapsedHeight) {
            mHandle.setVisibility(View.GONE);
        } else {
            mHandle.setVisibility(View.VISIBLE);
        }

        // Then let the usual thing happen
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    private class PanelToggler implements OnClickListener {
        public void onClick(View v) {
            Animation a;
            if (mExpanded) {
                a = new ExpandAnimation(mContentHeight, mCollapsedHeight);
                mListener.onCollapse(mHandle, mContent);
            } else {
                a = new ExpandAnimation(mCollapsedHeight, mContentHeight);
                mListener.onExpand(mHandle, mContent);
            }
            a.setDuration(mAnimationDuration);
            mContent.startAnimation(a);
            mExpanded = !mExpanded;
        }
    }

    private class ExpandAnimation extends Animation {
        private final int mStartHeight;
        private final int mDeltaHeight;

        public ExpandAnimation(int startHeight, int endHeight) {
            mStartHeight = startHeight;
            mDeltaHeight = endHeight - startHeight;
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
            lp.height = (int) (mStartHeight + mDeltaHeight * interpolatedTime);
            mContent.setLayoutParams(lp);
        }

        @Override
        public boolean willChangeBounds() {
            return true;
        }
    }

    public interface OnExpandListener {
        public void onExpand(View handle, View content); 
        public void onCollapse(View handle, View content);
    }

    private class DefaultOnExpandListener implements OnExpandListener {
        public void onCollapse(View handle, View content) {}
        public void onExpand(View handle, View content) {}
    }
}

And don't forget the attrs.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ExpandablePanel">
        <attr name="handle" format="reference" />
        <attr name="content" format="reference" />
        <attr name="collapsedHeight" format="dimension"/>
        <attr name="animationDuration" format="integer"/>
    </declare-styleable>
</resources>

See OP's example usage for the XML layout above. Here's an example for the listeners:

// Set expandable panel listener
ExpandablePanel panel = (ExpandablePanel)view.findViewById(R.id.foo);
panel.setOnExpandListener(new ExpandablePanel.OnExpandListener() {
    public void onCollapse(View handle, View content) {
        Button btn = (Button)handle;
        btn.setText("More");
    }
    public void onExpand(View handle, View content) {
        Button btn = (Button)handle;
        btn.setText("Less");
    }
});
ahal
  • 716
  • 5
  • 6
  • I'd also like to mention that you can easily get a fading out effect by setting/removing a LinearGradient with the content TextView's getPaint().setShader() method. – ahal Jun 17 '11 at 17:15
  • 12
    Just in case anyone else was having issues getting this to work, you need to add `xmlns:custom="http://schemas.android.com/apk/res/your.package.namespace"` in the root element of the xml layout file in order to use the custom attributes. See [here](http://kevindion.com/2011/01/custom-xml-attributes-for-android-widgets/) for more details. – Oli Glaser Aug 20 '12 at 00:38
  • setCollapsedHeight method should be like this, otherwise it wont set dynamically if not defined in xml. public void setCollapsedHeight( int collapsedHeight ) { mCollapsedHeight = collapsedHeight; android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams(); lp.height = mCollapsedHeight; mContent.setLayoutParams( lp ); } – Asanka Senavirathna Sep 19 '12 at 09:23
  • how to change the expand direction ? – ingyesid Apr 03 '13 at 17:26
  • BUG WARN: if in RT you get IllegalArgumentException("The handle.. -> change default handleId = -1 and THEN test "if (handleId == -1) {..". Seems APT uses 0 as attr. index. – Grzegorz Dev Dec 11 '14 at 09:49
  • Perfect answer, But I wish it had an arrow in handle layout with the animation showing its expanding and collapsing. – Milad Faridnia Oct 09 '16 at 06:59
10

I know this is an old question but for those who are interested, I made additions to what ahal and Pēteris Caune did.

Additions

  1. Included a layout to contain the horizontal view and the more button(refer to image from Pēteris Caune's question)
  2. The layout, instead of just the button is removed when there is no overflow
  3. Hidden text is shown or hidden depending on state of button

Updated Code

ExpandablePanel Class

package com.example.myandroidhustles;
import com.example.myandroidhustles.R;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.Transformation;
import android.widget.LinearLayout;

public class ExpandablePanel extends LinearLayout {

    private final int mHandleId;
    private final int mContentId;
    private final int mViewGroupId;

    private final boolean isViewGroup;

    private View mHandle;
    private View mContent;
    private ViewGroup viewGroup;

    private boolean mExpanded = false;
    private int mCollapsedHeight = 0;
    private int mContentHeight = 0;
    private int mAnimationDuration = 0;

    private OnExpandListener mListener;

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

    public ExpandablePanel(Context context, AttributeSet attrs) {
        super(context, attrs);
        mListener = new DefaultOnExpandListener();

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ExpandablePanel, 0, 0);

        // How high the content should be in "collapsed" state
        mCollapsedHeight = (int) a.getDimension(R.styleable.ExpandablePanel_collapsedHeight, 0.0f);

        // How long the animation should take
        mAnimationDuration = a.getInteger(R.styleable.ExpandablePanel_animationDuration, 500);

        int handleId = a.getResourceId(R.styleable.ExpandablePanel_handle, 0);
        if (handleId == 0) {
            throw new IllegalArgumentException(
                "The handle attribute is required and must refer "
                    + "to a valid child.");
        }

        int contentId = a.getResourceId(R.styleable.ExpandablePanel_content, 0);
        if (contentId == 0) {
            throw new IllegalArgumentException("The content attribute is required and must refer to a valid child.");
        }

        int isViewGroupId = a.getResourceId(R.styleable.ExpandablePanel_isviewgroup, 0);
        int viewGroupId = a.getResourceId(R.styleable.ExpandablePanel_viewgroup, 0);
//        isViewGroup = findViewById(isViewGroupId);
        isViewGroup = a.getBoolean(R.styleable.ExpandablePanel_isviewgroup, false);
        if (isViewGroup) {
            mViewGroupId = viewGroupId;
        }
        else {
            mViewGroupId = 0;
        }

        mHandleId = handleId;
        mContentId = contentId;        

        a.recycle();
    }

    public void setOnExpandListener(OnExpandListener listener) {
        mListener = listener; 
    }

    public void setCollapsedHeight(int collapsedHeight) {
        mCollapsedHeight = collapsedHeight;
    }

    public void setAnimationDuration(int animationDuration) {
        mAnimationDuration = animationDuration;
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        mHandle = findViewById(mHandleId);        
        if (mHandle == null) {
            throw new IllegalArgumentException(
                "The handle attribute is must refer to an"
                    + " existing child.");
        }
        if(mViewGroupId != 0) {
            viewGroup = (ViewGroup) findViewById(mViewGroupId);
        }


        mContent = findViewById(mContentId);
        if (mContent == null) {
            throw new IllegalArgumentException(
                "The content attribute must refer to an"
                    + " existing child.");
        }

        android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
        lp.height = mCollapsedHeight;
        mContent.setLayoutParams(lp);

        mHandle.setOnClickListener(new PanelToggler());
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // First, measure how high content wants to be
        mContent.measure(widthMeasureSpec, MeasureSpec.UNSPECIFIED);
        mContentHeight = mContent.getMeasuredHeight();

        if (mContentHeight < mCollapsedHeight) {
            viewGroup.setVisibility(View.GONE);
//            mHandle.setVisibility(View.GONE);

        } else {
            viewGroup.setVisibility(View.VISIBLE);
//            mHandle.setVisibility(View.VISIBLE);
        }

        // Then let the usual thing happen
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    private class PanelToggler implements OnClickListener {
        public void onClick(View v) {
            Animation a;
            if (mExpanded) {
                a = new ExpandAnimation(mContentHeight, mCollapsedHeight);
                mListener.onCollapse(mHandle, mContent);
            } else {
                a = new ExpandAnimation(mCollapsedHeight, mContentHeight);
                mListener.onExpand(mHandle, mContent);
            }
            a.setDuration(mAnimationDuration);
            if(mContent.getLayoutParams().height == 0) //Need to do this or else the animation will not play if the height is 0
           {
            android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
            lp.height = 1;
            mContent.setLayoutParams(lp);
            mContent.requestLayout();
          }
            mContent.startAnimation(a);
            mExpanded = !mExpanded;
        }
    }

    private class ExpandAnimation extends Animation {
        private final int mStartHeight;
        private final int mDeltaHeight;

        public ExpandAnimation(int startHeight, int endHeight) {
            mStartHeight = startHeight;
            mDeltaHeight = endHeight - startHeight;
        }

        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
            lp.height = (int) (mStartHeight + mDeltaHeight * interpolatedTime);
            mContent.setLayoutParams(lp);
        }

        @Override
        public boolean willChangeBounds() {
            return true;
        }
    }

    public interface OnExpandListener {
        public void onExpand(View handle, View content); 
        public void onCollapse(View handle, View content);
    }

    private class DefaultOnExpandListener implements OnExpandListener {
        public void onCollapse(View handle, View content) {}
        public void onExpand(View handle, View content) {}
    }
}

attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ExpandablePanel">
        <attr name="handle" format="reference" />
        <attr name="content" format="reference" />
        <attr name="viewgroup" format="reference"/>
        <attr name="isviewgroup" format="boolean"/>
        <attr name="collapsedHeight" format="dimension"/>
        <attr name="animationDuration" format="integer"/>
    </declare-styleable>
</resources>

Layout: tryExpandablePanel.xml

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

    <com.example.myandroidhustles.ExpandablePanel
        android:id="@+id/expandablePanel"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:orientation="vertical"
        example:collapsedHeight="50dip"
        example:content="@+id/value"
        example:handle="@+id/expand"
        example:isviewgroup="true"
        example:viewgroup="@+id/expandL" >

        <TextView
            android:id="@+id/value"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:maxHeight="100dip" />

        <LinearLayout
            android:id="@+id/expandL"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:paddingLeft="10dp"
            android:weightSum="100" >

            <View
                android:id="@+id/view"
                android:layout_width="fill_parent"
                android:layout_height="1dp"
                android:layout_gravity="center_vertical|left"
                android:layout_weight="30"
                android:background="@android:color/darker_gray" />

            <Button
                android:id="@+id/expand"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="right"
                android:layout_weight="70"
                android:text="More" />
        </LinearLayout>
    </com.example.myandroidhustles.ExpandablePanel>

</LinearLayout>

Implementation: ExpandablePanelImplementation Class

package com.example.myandroidhustles;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;


public class ExpandablePanelImplementation extends Activity {
    ExpandablePanel panel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.tryexpandable);
        TextView text;
        text = (TextView)findViewById(R.id.value);

            text.setText("ksaflfsklafjsfj sdfjklds fj asklfjklasfjskladf fjslkafjf" +
                    "asfkdaslfjsf;sjdaflkadsjflkdsajfkldsajflkdsanfvsjvfdskljflkdnjdsadf" +
                    "askfvdsklfjvsdlkfjdsklvdkjkdsadsj;lkasjdfklvsddsjkdsljskldfj");


      panel = (ExpandablePanel)findViewById(R.id.expandablePanel);

      panel.setOnExpandListener(new ExpandablePanel.OnExpandListener() {
          public void onCollapse(View handle, View content) {
              Button btn = (Button)handle;
              btn.setText("More");

              panel.setCollapsedHeight(100);
          }
          public void onExpand(View handle, View content) {
              Button btn = (Button)handle;
              panel.setCollapsedHeight(50);
              btn.setText("Less");
          }
      });


    }

}
euniceadu
  • 868
  • 16
  • 34
  • Thank you. However, there may be a problem that the content view may not yet measured before the expandablelayout expand. I have tried the set a long text on the TextView as the content and then call handle.performClick() to manually expand the expandablelayout. At the first time, the layout does not expand much unless I manually call expandablelayout.measure(0, 0) at that moment. – Yeung Apr 24 '13 at 05:27
  • is there anyway to expand/collapse programmatically ? – Milad Faridnia Oct 09 '16 at 09:42
10

Have you tried having a ScrollView at a set size that you make not clickable and not focusable? Then, when you expand it, you could just animate it getting bigger.

CaseyB
  • 24,780
  • 14
  • 77
  • 112
5

Great extension ahal. I have modified your code slightly to fix a bug I found.

I added this around line 128, after a.setDuration(mAnimationDuration); in PanelToggler

if(mContent.getLayoutParams().height == 0) //Need to do this or else the animation will not play if the height is 0
{
    android.view.ViewGroup.LayoutParams lp = mContent.getLayoutParams();
    lp.height = 1;
    mContent.setLayoutParams(lp);
    mContent.requestLayout();
}

I found that if the content height was 0, then the animation would not play, so it had to set it to 1 before the animation.

Dylan
  • 2,614
  • 2
  • 18
  • 21