Expandable RecyclerView

A custom RecyclerView which allows for an expandable view to be attached to each ViewHolder

JavaDocs
View the Project on GitHub

Overview

Expandable RecyclerView can be used with any stock Android RecyclerView to provide expandable items.

Basic Setup

First, define a stock RecyclerView in a layout file and inflate it in an Activity/Fragment as normal.

Define the parent/child relationship by implementing Parent and specifying the your child object type with the generic. You will need to override getChildList(), which will return a List of your child type, and isInitiallyExpanded(), which determines whether the parent will be expanded when initialized.

public class Recipe implements Parent<Ingredient> {

    // a recipe contains several ingredients
    private List<Ingredient> mIngredients;

    public Recipe(String name, List<Ingredient> ingredients) {
        mIngredients = ingredients;
    }

    @Override
    public List<Ingredient> getChildList() {
        return mIngredients;
    }

    @Override
    public boolean isInitiallyExpanded() {
        return false;
    }
}

Create two ViewHolders to hold parent and child views by extending ParentViewHolder and ChildViewHolder respectively. Handle the normal ViewHolder behavior by finding any views to be held, and implement a binding method.

public class RecipeViewHolder extends ParentViewHolder {

    private TextView mRecipeTextView;

    public RecipeViewHolder(View itemView) {
        super(itemView);
        mRecipeTextView = itemView.findViewById(R.id.recipe_textview);
    }

    public void bind(Recipe recipe) {
        mRecipeTextView.setText(recipe.getName());
    }
}
public class IngredientViewHolder extends ChildViewHolder {

    private TextView mIngredientTextView;

    public IngredientViewHolder(View itemView) {
        super(itemView);
        mIngredientTextView = itemView.findViewById(R.id.ingredient_textview);
    }

    public void bind(Ingredient ingredient) {
        mIngredientTextView.setText(ingredient.getName());
    }
}

Next, create an adapter that extends ExpandableRecyclerAdapter and takes 4 generic types:

Unlike a normal RecyclerView.Adapter with a single set of onCreate and onBind methods, ExpandableRecyclerAdapter has a set for ParentViewHolders and another set for ChildViewHolders. Note that you do not need to override getItemCount(), this is handled by the library.

public class RecipeAdapter extends ExpandableRecyclerAdapter<Recipe, Ingredient, RecipeViewHolder, IngredientViewHolder> {

    private LayoutInflater mInflater;

    public RecipeAdapter(Context context, @NonNull List<Recipe> recipeList) {
        super(parentItemList);
        mInflater = LayoutInflater.from(context);
    }

    // onCreate ...
    @Override
    public RecipeViewHolder onCreateParentViewHolder(@NonNull ViewGroup parentViewGroup, int viewType) {
        View recipeView = mInflater.inflate(R.layout.recipe_view, parentViewGroup, false);
        return new RecipeViewHolder(recipeView);
    }

    @Override
    public IngredientViewHolder onCreateChildViewHolder(@NonNull ViewGroup childViewGroup, int viewType) {
        View ingredientView = mInflater.inflate(R.layout.ingredient_view, childViewGroup, false);
        return new IngredientViewHolder(ingredientView);
    }

    // onBind ...
    @Override
    public void onBindParentViewHolder(@NonNull RecipeViewHolder recipeViewHolder, int parentPosition, @NonNull Recipe recipe) {
        recipeViewHolder.bind(recipe);
    }

    @Override
    public void onBindChildViewHolder(@NonNull IngredientViewHolder ingredientViewHolder, int parentPosition, int childPosition, @NonNull Ingredient ingredient) {
        ingredientViewHolder.bind(ingredient);
    }
}

Finally, instantiate this adapter while passing in data, and hand it to RecyclerView. Make sure you give RecyclerView a LayoutManager:

Ingredient beef = new Ingredient("beef");
Ingredient cheese = new Ingredient("cheese");
Ingredient salsa = new Ingredient("salsa");
Ingredient tortilla = new Ingredient("tortilla");

Recipe taco = new Recipe(Arrays.asList(beef, cheese, salsa, tortilla));
Recipe quesadilla = new Recipe(Arrays.asList(cheese, tortilla));
List<Recipe> recipes = Arrays.asList(taco, quesadilla);

mRecyclerView = (RecyclerView) findViewById(R.id.recyclerview);
RecipeAdapter adapter = new RecipeAdapter(this, recipes);
mRecyclerView.setAdapter(adapter);
mRecyclerView.setLayoutManager(new LinearLayoutManager(this));

Custom Expand/Collapse Button

By default, clicking the entire parent view will trigger expansion or collapsing. However, Expandable RecyclerView allows for one or more sub views to handle expansion/collapsing and ignore touch event on the parent view itself.

The ParentViewHolder implementation should override shouldItemViewClickToggleExpansion() to return false. Then set a click listener on the desired button within the ParentViewHolder implementation and call expandView() to trigger the expansion or collapseView() to trigger a collapse.

public class RecipeViewHolder extends ParentViewHolder {

    private ImageView mArrowExpandImageView;
    private TextView mRecipeTextView;

    public RecipeViewHolder(View itemView) {
        super(itemView);
        mRecipeTextView = itemView.findViewById(R.id.recipe_textview);

        mArrowExpandImageView = (ImageView) itemView.findViewById(R.id.arrow_expand_imageview);
        mArrowExpandImageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (isExpanded()) {
                    collapseView();
                } else {
                    expandView();
                }
            }
        });
    }

    @Override
    public boolean shouldItemViewClickToggleExpansion() {
        return false;
    }

    ...

}

Listening for Expansion/Collapse Inside Parent ViewHolder

To provide additional functionality inside the ParentViewHolder when it has been expanded or collapsed, simply implement ParentViewHolder.onExpansionToggled(Boolean expanded). This is the perfect place to trigger additional transition animations, such as the rotating arrow animation in the above example.

Triggering Expansion/Collapse Programatically

Other components can trigger expansion/collapse programmatically. ExpandableRecyclerAdapter features expandParent(int position) and expandParent(P parent) to expand a list item by its adapter position or by its own reference.

Similar methods exist for collapsing a list item: collapseParent(int position) and collapseParent(P parent).

To expand or collapse all items in the list at once, use expandAllParents() and collapseAllParents().

Listening for Expansion/Collapse Outside of the Adapter

While ParentViewHolder.onExpansionToggled(boolean expanded) is useful for listening for view changes within individual parent views in the RecyclerView, Expandable RecyclerView also allows listeners outside of the Adapter to be notified of expand/collapse events. Simply create a ExpandableRecyclerAdapter.ExpandCollapseListener implementation and passing that implementation to the Adapter.

RecipeAdapter adapter = new RecipeAdapter(this, recipes);

adapter.setExpandCollapseListener(new ExpandableRecyclerAdapter.ExpandCollapseListener() {
    @Override
    public void onParentExpanded(int parentPosition) {
        Recipe expandedRecipe = recipes.get(position);
        // ...
    }

    @Override
    public void onParentCollapsed(int parentPosition) {
        Recipe collapsedRecipe = recipes.get(position);
        // ...
    }
});

mRecyclerView.setAdapter(adapter);

Saving and Restoring Collapsed and Expanded States

It's possible to save the expanded/collapsed state of the items in the RecyclerView across device configuration changes, low memory, or any time onSavedInstance state would be called.

To save expanded/collapsed states call ExpandableRecyclerAdapter.onSaveInstanceState(Bundle savedInstanceState). To restore that state call ExpandableRecyclerAdapter.onRestoreInstanceState(Bundle savedInstanceState).

protected void onSaveInstanceState(Bundle savedInstanceState) {
    super.onSaveInstanceState(savedInstanceState);
    mAdapter.onSaveInstanceState(savedInstanceState);
}

@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
    super.onRestoreInstanceState(savedInstanceState);
    mAdapter.onRestoreInstanceState(savedInstanceState);
}

Dataset Changes

During the life of the RecyclerView items may be added and removed from the list. Please note that the traditional notifyDataSetChanged() of RecyclerView.Adapter does not work as intended. Consider notifyParentDataSetChanged() instead.

Instead Expandable RecyclerView provides a set of notify methods with the ability to inform the adapter of changes to the list of Parents.

// Parent Changes
notifyParentInserted(int parentPosition)
notifyParentRemoved(int parentPosition)
notifyParentChanged(int parentPosition)
notifyParentMoved(int fromParentPosition, int toParentPosition)
notifyParentRangeInserted(int parentPositionStart, int itemCount)
notifyParentRangeRemoved(int parentPositionStart, int itemCount)
notifyParentRangeChanged(int parentPositionStart, int itemCount)

// Child Changes
notifyChildInserted(int parentPosition, int childPosition)
notifyChildRemoved(int parentPosition, int childPosition)
notifyChildChanged(int parentPosition, int childPosition)
notifyChildMoved(int parentPosition, int fromChildPosition, int toChildPosition)
notifyChildRangeInserted(int parentPosition, int childPositionStart, int itemCount)
notifyChildRangeRemoved(int parentPosition, int childPositionStart, int itemCount)
notifyChildRangeChanged(int parentPosition, int childPositionStart, int itemCount)

// Unspecific Change (not recommended, use above specific methods instead)
notifyParentDataSetChanged(boolean preserveExpansionState)

Multiple view types

Expandable RecyclerView also supports having multiple view types for your parent and child item views. To do so override the following methods in your ExpandableRecyclerAdapter:

public class RecipeAdapter extends ExpandableRecyclerAdapter<Recipe, Ingredient, RecipeViewHolder, IngredientViewHolder> {
    private static final int PARENT_VEGETARIAN = 0;
    private static final int PARENT_NORMAL = 1;
    private static final int CHILD_VEGETARIAN = 2;
    private static final int CHILD_NORMAL = 3;

    ...

    @Override
    public int getParentItemViewType(int parentPosition) {
        if (mRecipeList.get(parentPosition).isVegetarian()) {
            return PARENT_VEGETARIAN;
        } else {
            return PARENT_NORMAL;
        }
    }

    @Override
    public int getChildItemViewType(int parentPosition, int childPosition) {
        Ingredient ingredient = mRecipeList.get(parentPosition).getIngredient(childPosition);
        if (ingredient.isVegetarian()) {
            return CHILD_VEGETARIAN;
        } else {
            return CHILD_NORMAL;
        }
    }

    @Override
    public boolean isParentViewType(int viewType) {
        return viewType == PARENT_VEGETARIAN || viewType == PARENT_NORMAL;
    }

    ...
}

The biggest thing to note is that if you override getParentItemViewType(int) you must override isParentViewType(int) and identify which view types are of a parent row. After giving the view type for a given position you will just need to create the correct view in your creation methods:

public class RecipeAdapter extends ExpandableRecyclerAdapter<Recipe, Ingredient, RecipeViewHolder, IngredientViewHolder> {
    private static final int PARENT_VEGETARIAN = 0;
    private static final int PARENT_NORMAL = 1;
    private static final int CHILD_VEGETARIAN = 2;
    private static final int CHILD_NORMAL = 3;

    ...

    @Override
    public RecipeViewHolder onCreateParentViewHolder(@NonNull ViewGroup parentViewGroup, int viewType) {
        View recipeView;
        switch (viewType) {
            default:
            case PARENT_NORMAL:
                recipeView = mInflater.inflate(R.layout.recipe_view, parentViewGroup, false);
                break;
            case PARENT_VEGETARIAN:
                recipeView = mInflater.inflate(R.layout.vegetarian_recipe_view, parentViewGroup, false);
                break;
        }
        return new RecipeViewHolder(recipeView);
    }

    @Override
    public IngredientViewHolder onCreateChildViewHolder(@NonNull ViewGroup childViewGroup, int viewType) {
        View ingredientView;
        switch (viewType) {
            default:
            case CHILD_NORMAL:
                ingredientView = mInflater.inflate(R.layout.ingredient_view, childViewGroup, false);
                break;
            case CHILD_VEGETARIAN:
                ingredientView = mInflater.inflate(R.layout.vegetarian_ingredient_view, childViewGroup, false);
                break;
        }
        return new IngredientViewHolder(ingredientView);
    }

    ...
}

Download

v3.0.0-RC1 AAR

Gradle

compile 'com.bignerdranch.android:expandablerecyclerview:3.0.0-RC1'

Maven

<dependency>
    <groupId>com.bignerdranch.android</groupId>
    <artifactId>expandablerecyclerview</artifactId>
    <version>3.0.0-RC1</version>
</dependency>

License

The MIT License (MIT)

Copyright (c) 2014 Big Nerd Ranch

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.