Widget Catalog: ExpandableListView

Android does not have a “tree” widget, allowing users to navigate an arbitrary hierarchy of stuff. In large part, that is because such trees are difficult to navigate on small touchscreens with comparatively large fingers.

Android does have ExpandableListView, a subclass of ListView that supports a two-layer hierarchy: groups and children. Groups can be expanded to show their children or collapsed to hide them, and you can get control on various events for the groups or the children.

Key Usage Tips

Android offers an ExpandableListActivity as a counterpart to its ListActivity. However, it does not offer an ExpandableListFragment. This is not a major issue, as you can work with an ExpandableListView inside a regular Fragment yourself, just as you would for most other widgets not named ListView.

Rather than use a ListAdapter with ExpandableListView, you will use an ExpandableListAdapter, where you can control separate details for groups and children. These include:

In many cases, though, the complexity of managing groups and children will steer you down the path of extending BaseExpandableListAdapter and handling all of the view construction yourself. There are many methods that you will need to implement:

There are four major events that you will be able to respond to with respect to the user’s interaction with an ExpandableListView:

If you use setOnGroupClickListener() to be notified about clicks on a group, be sure to return false from your implementation of the onGroupClick() method required by the OnGroupClickListener interface. If you return true, you consume the click event, which prevents ExpandableListView from using that event to expand or collapse the group.

A Sample Usage

The sample project can be found in WidgetCatalog/ExpandableListView.

Layout:

<ExpandableListView xmlns:android="http://schemas.android.com/apk/res/android"
  android:id="@+id/elv"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

</ExpandableListView>
(from WidgetCatalog/ExpandableListView/app/src/main/res/layout/activity_main.xml)

JSON data:


{
  "Group A": ["Child A1", "Child A2", "Child A3"],
  "Group B": ["Child B1", "Child B2"],
  "Group C": ["Child C1"],
  "Group D": [],
  "Group E": ["Child E1", "Child E2", "Child E3"]
}

Activity:

package com.commonsware.android.wc.elv;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ExpandableListAdapter;
import android.widget.ExpandableListView;
import android.widget.ExpandableListView.OnChildClickListener;
import android.widget.ExpandableListView.OnGroupClickListener;
import android.widget.ExpandableListView.OnGroupCollapseListener;
import android.widget.ExpandableListView.OnGroupExpandListener;
import android.widget.Toast;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import org.json.JSONObject;

public class MainActivity extends Activity implements
    OnChildClickListener, OnGroupClickListener, OnGroupExpandListener,
    OnGroupCollapseListener {
  private ExpandableListAdapter adapter=null;

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

    InputStream raw=getResources().openRawResource(R.raw.sample);
    BufferedReader in=new BufferedReader(new InputStreamReader(raw));
    String str;
    StringBuffer buf=new StringBuffer();

    try {
      while ((str=in.readLine()) != null) {
        buf.append(str);
        buf.append('\n');
      }

      in.close();

      JSONObject model=new JSONObject(buf.toString());

      ExpandableListView elv=(ExpandableListView)findViewById(R.id.elv);

      adapter=new JSONExpandableListAdapter(getLayoutInflater(), model);
      elv.setAdapter(adapter);

      elv.setOnChildClickListener(this);
      elv.setOnGroupClickListener(this);
      elv.setOnGroupExpandListener(this);
      elv.setOnGroupCollapseListener(this);
    }
    catch (Exception e) {
      Log.e(getClass().getName(), "Exception reading JSON", e);
    }
  }

  @Override
  public boolean onChildClick(ExpandableListView parent, View v,
                              int groupPosition, int childPosition,
                              long id) {
    Toast.makeText(this,
                   adapter.getChild(groupPosition, childPosition)
                          .toString(), Toast.LENGTH_SHORT).show();

    return(false);
  }

  @Override
  public boolean onGroupClick(ExpandableListView parent, View v,
                              int groupPosition, long id) {
    Toast.makeText(this, adapter.getGroup(groupPosition).toString(),
                   Toast.LENGTH_SHORT).show();

    return(false);
  }

  @Override
  public void onGroupExpand(int groupPosition) {
    Toast.makeText(this,
                   "Expanding: "
                       + adapter.getGroup(groupPosition).toString(),
                   Toast.LENGTH_SHORT).show();
  }

  @Override
  public void onGroupCollapse(int groupPosition) {
    Toast.makeText(this,
                   "Collapsing: "
                       + adapter.getGroup(groupPosition).toString(),
                   Toast.LENGTH_SHORT).show();
  }
}
(from WidgetCatalog/ExpandableListView/app/src/main/java/com/commonsware/android/wc/elv/MainActivity.java)

This activity loads up a JSON file from a raw resource on the main application thread in onCreate(), which is not a good idea. It would be better to do that work in a background thread, perhaps an AsyncTask managed by a retained fragment. The implementation shown here is designed to keep the sample small, not to demonstrate the best way to load data from a raw resource.

Adapter:

package com.commonsware.android.wc.elv;

import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseExpandableListAdapter;
import android.widget.TextView;
import java.util.Iterator;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

public class JSONExpandableListAdapter extends
    BaseExpandableListAdapter {
  LayoutInflater inflater=null;
  JSONObject model=null;

  JSONExpandableListAdapter(LayoutInflater inflater, JSONObject model) {
    this.inflater=inflater;
    this.model=model;
  }

  @Override
  public int getGroupCount() {
    return(model.length());
  }

  @Override
  public Object getGroup(int groupPosition) {
    @SuppressWarnings("rawtypes")
    Iterator i=model.keys();

    while (groupPosition > 0) {
      i.next();
      groupPosition--;
    }

    return(i.next());
  }

  @Override
  public long getGroupId(int groupPosition) {
    return(groupPosition);
  }

  @Override
  public View getGroupView(int groupPosition, boolean isExpanded,
                           View convertView, ViewGroup parent) {
    if (convertView == null) {
      convertView=
          inflater.inflate(android.R.layout.simple_expandable_list_item_1,
                           parent, false);
    }

    TextView tv=
        ((TextView)convertView.findViewById(android.R.id.text1));
    tv.setText(getGroup(groupPosition).toString());

    return(convertView);
  }

  @Override
  public int getChildrenCount(int groupPosition) {
    try {
      JSONArray children=getChildren(groupPosition);

      return(children.length());
    }
    catch (JSONException e) {
      // JSONArray is really annoying
      Log.e(getClass().getSimpleName(), "Exception getting children", e);
    }

    return(0);
  }

  @Override
  public Object getChild(int groupPosition, int childPosition) {
    try {
      JSONArray children=getChildren(groupPosition);

      return(children.get(childPosition));
    }
    catch (JSONException e) {
      // JSONArray is really annoying
      Log.e(getClass().getSimpleName(),
            "Exception getting item from JSON array", e);
    }

    return(null);
  }

  @Override
  public long getChildId(int groupPosition, int childPosition) {
    return(groupPosition * 1024 + childPosition);
  }

  @Override
  public View getChildView(int groupPosition, int childPosition,
                           boolean isLastChild, View convertView,
                           ViewGroup parent) {
    if (convertView == null) {
      convertView=
          inflater.inflate(android.R.layout.simple_list_item_1, parent,
                           false);
    }

    TextView tv=(TextView)convertView;
    tv.setText(getChild(groupPosition, childPosition).toString());

    return(convertView);
  }

  @Override
  public boolean isChildSelectable(int groupPosition, int childPosition) {
    return(true);
  }

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

  private JSONArray getChildren(int groupPosition) throws JSONException {
    String key=getGroup(groupPosition).toString();

    return(model.getJSONArray(key));
  }
}
(from WidgetCatalog/ExpandableListView/app/src/main/java/com/commonsware/android/wc/elv/JSONExpandableListAdapter.java)

This adapter wraps a JSONObject and assumes that the JSON structure is an object, keyed by strings, whose values are arrays of strings. The object returned by getGroup() is the key for that group’s position; the object returned by getChild() is the string at that child’s array index for it’s group’s array. Since the data structure is treated as immutable, and since there are no other better IDs in the data structure itself, the group ID is simply the group’s position, and the child’s ID is simply a mash-up of the group and child positions.

Visual Representation

This is what an ExpandableListView looks like in a few different Android versions and configurations, based upon the sample app shown above.

Android 2.3.3, Portrait
Figure 1045: Android 2.3.3, Portrait

Android 4.0.3, Portrait
Figure 1046: Android 4.0.3, Portrait

Note that while the data in the JSON file has the groups sorted alphabetically, because JSONObject effectively loads its data into a HashMap, the sorting gets lost in the data model, which is why the groups appear out of order.

Also note that the visual representation of the “collapsed” and “expanded” states is controlled by the ExpandableListAdapter and the view used for the groups. In this sample, we use android.R.layout.simple_expandable_list_item_1 for the groups, which gives us the caret designation for expanded versus collapsed states in 4.0.3 and the lower-left arrowhead-in-circle icon for 2.3.3. You can create your own rows with your own indicators as you see fit.