Reputation: 155
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
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.)
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