DoubleZ
DoubleZ

Reputation: 155

Custom expandable card with child views

I am new to Android development and feel like this is a really trivial problem, but I cannot word it well enough to find a solution online, so I might as well ask the question here.

My goal is to create a reusable component that is essentially an expandable card like the one described here: https://material.io/design/components/cards.html#behavior.

To do it, I created a custom view that extends a CardView:

public class ExpandableCardView extends CardView {

    public ExpandableCardView(Context context) {
        super(context);
    }

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

        // get custom attributes
        TypedArray array = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ExpandableCardView, 0, 0);
        String heading = array.getString(R.styleable.ExpandableCardView_heading);
        array.recycle();

        // inflate the layout
        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.expandable_card_view, this, true);

        // set values
        TextView headingTextView = findViewById(R.id.card_heading);
        headingTextView.setText(heading.toUpperCase());

        // set collapse/expand click listener
        ImageView collapseExpandButton = findViewById(R.id.collapse_expand_card_button);
        collapseExpandButton.setOnClickListener((View v) -> toggleCardBodyVisibility());
    }

    private void toggleCardBodyVisibility() {
        LinearLayout description = findViewById(R.id.card_body);
        ImageView imageButton = findViewById(R.id.collapse_expand_card_button);

        if (description.getVisibility() == View.GONE) {
            description.setVisibility(View.VISIBLE);
            imageButton.setImageResource(R.drawable.ic_arrow_up);
        } else {
            description.setVisibility(View.GONE);
            imageButton.setImageResource(R.drawable.ic_arrow_down);
        }
    }
}

And the layout:

<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/expandable_card_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:elevation="16dp"
    android:animateLayoutChanges="true"
    app:cardCornerRadius="4dp">

    <RelativeLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/card_header"
        android:padding="12dp"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:orientation="horizontal" >

        <TextView
            android:id="@+id/card_heading"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="18sp"
            android:textColor="@color/colorPrimary"
            android:layout_alignParentLeft="true"
            android:text="HEADING"/>

        <ImageView
            android:id="@+id/collapse_expand_card_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            app:srcCompat="@drawable/ic_arrow_down"/>
    </RelativeLayout>

    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/card_body"
        android:padding="12dp"
        android:layout_marginTop="28dp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:visibility="gone" >
    </LinearLayout>
</androidx.cardview.widget.CardView>

Ultimately I want to be able to use it like so in my activities, usually multiple instances per activity:

<xx.xyz.yy.customviews.ExpandableCardView
    android:id="@+id/card_xyz"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    custom_xxx:heading="SOME HEADING" >

    <SomeView></SomeView>

</xx.xyz.yy.customviews.ExpandableCardView>

Where SomeView is any text, image, layout or another custom view altogether, typically with data bound from the activity.

How do I get it to render SomeView inside the card body? I want to take whatever child structure is defined within the custom view and show it in the card body when it is expanded. Hope I made it easy to understand.

Upvotes: 3

Views: 1549

Answers (1)

Cheticamp
Cheticamp

Reputation: 62841

I think that a better approach would be to define the layout that will be inserted into the CardView ("SomeView") in a separate file and reference it with a custom attribute like this:

<xx.xyz.yy.customviews.ExpandableCardView
    android:id="@+id/card_xyz"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    custom_xxx:heading="SOME HEADING" 
    custom_xxx:expandedView="@layout/some_view"/>

I'll explain my rationale at the end, but let's look at an answer to your question as stated.

What you are probably seeing with your code is SomeView and expandable_card_view appearing all at once in the layout. This is because SomeView is implicitly inflated with the CardView and then expandable_card_view is added through an explicit inflation. Since working with layout XML files directly is difficult, we will let the implicit inflation occur such that the custom CardView just contains SomeView.

We will then remove SomeView from the layout, stash it, and insert expandable_card_view in its place. Once this is done, SomeView will be reinserted into the LinearLayout with the id card_body. All this has to be done after the completion of the initial layout. To get control after the initial layout is complete, we will use ViewTreeObserver.OnGlobalLayoutListener. Here is the updated code. (I have removed a few things to simplify the example.)

[video]

ExpandableCardView

public class ExpandableCardView extends CardView {

    public ExpandableCardView(Context context) {
        super(context);
    }

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

        // Get control after layout is complete.
        getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // Remove listener so it won't be called again
                getViewTreeObserver().removeOnGlobalLayoutListener(this);

                // Get the view we want to insert into the LinearLayut called "card_body" and
                // remove it from the custom CardView.
                View childView = getChildAt(0);
                removeAllViews();
                LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
                inflater.inflate(R.layout.expandable_card_view, ExpandableCardView.this, true);

                // Insert the view into the LinearLayout.
                ((LinearLayout) findViewById(R.id.card_body)).addView(childView);

                // And the rest of the stuff...
                TextView headingTextView = findViewById(R.id.card_heading);
                headingTextView.setText("THE HEADING");

                // set collapse/expand click listener
                ImageView collapseExpandButton = findViewById(R.id.collapse_expand_card_button);
                collapseExpandButton.setOnClickListener((View v) -> toggleCardBodyVisibility());
            }
        });
    }

    private void toggleCardBodyVisibility() {
        LinearLayout description = findViewById(R.id.card_body);
        ImageView imageButton = findViewById(R.id.collapse_expand_card_button);

        if (description.getVisibility() == View.GONE) {
            description.setVisibility(View.VISIBLE);
            imageButton.setImageResource(R.drawable.ic_arrow_up);
        } else {
            description.setVisibility(View.GONE);
            imageButton.setImageResource(R.drawable.ic_arrow_down);
        }
    }
}

expandable_card_view.java
The CardView tag is changed to merge to avoid a CardView directly nested within a CardView.

<merge
    android:id="@+id/expandable_card_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:elevation="16dp"
    android:animateLayoutChanges="true"
    app:cardCornerRadius="4dp">

    <RelativeLayout
        android:id="@+id/card_header"
        android:padding="12dp"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:orientation="horizontal" >

        <TextView
            android:id="@+id/card_heading"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="18sp"
            android:textColor="@color/colorPrimary"
            android:layout_alignParentLeft="true"
            android:text="HEADING"/>

        <ImageView
            android:id="@+id/collapse_expand_card_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            app:srcCompat="@drawable/ic_arrow_down"/>
    </RelativeLayout>

    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/card_body"
        android:padding="12dp"
        android:layout_marginTop="28dp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal"
        android:visibility="gone" >

    </LinearLayout>
</merge>

activity_main.xml

<LinearLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.example.customcardview.ExpandableCardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:animateLayoutChanges="true">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <ImageView
                android:id="@+id/imageView"
                android:layout_width="100dp"
                android:layout_height="100dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:srcCompat="@drawable/ic_android" />


            <TextView
                android:id="@+id/childView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Say my name."
                android:textSize="12sp"
                android:textStyle="bold"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/imageView" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </com.example.customcardview.ExpandableCardView>

</LinearLayout>

So, why do I suggest that you use a custom attribute to include SomeView in the layout as I identified at the beginning? In the way outlined above, SomeView will always be inflated and there is some effort to switch the layout around although SomeView may never be shown. This would be expensive if you have a lot of custom CardViews in a RecyclerView for instance. By using a custom attribute to reference an external layout, you would only need to inflate SomeView when it is being shown and the code would be a lot simpler and easier to understand. Just my two cents and it may not really matter depending upon how you intend to use the custom view.

Upvotes: 1

Related Questions