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.
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:
SimpleExpandableListAdapter
, roughly analogous to ArrayAdapter
, where your data
resides in a List
of Map
objects for groups, and a List
of a List
of Map
objects for the childrenCursorTreeAdapter
and SimpleCursorTreeAdapter
, roughly analogous to CursorAdapter
and SimpleCursorAdapter
, for mapping data in a Cursor
to rows and columnsIn 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:
getGroupCount()
, to return the number of groupsgetGroup()
and getGroupId()
, to return an Object
and unique int
ID for a group
given its positiongetGroupView()
, to return the View
that should be used to render the group,
perhaps using the built-in android.R.layout.simple_expandable_list_item_1
that is set
up for such groups and handles rendering the expanded and collapsed statesgetChildrenCount()
, to return the number of children for a given groupgetChild()
and getChildId()
, to return an Object
and unique int
ID for a child
given its position (and its group’s position)getChildView()
, to return the View
that should be used to render the child, given
its position and its group’s positionisChildSelectable()
, to indicate if the user can select a given child, given its position
and its group’s positionhasStableIds()
, to indicate if the ID values you returned from getGroupId()
and
getChildId()
will remain constant for the life of this adapterThere are four major events that you will be able to respond to with respect to the
user’s interaction with an ExpandableListView
:
setOnChildClickListener()
)setOnGroupClickListener()
)setOnGroupExpandListener()
) or collapse (setOnGroupCollapseListener()
)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.
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>
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();
}
}
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));
}
}
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.
This is what an ExpandableListView
looks like in a few different Android
versions and configurations, based upon the sample app shown above.
Figure 1045: Android 2.3.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.