Sectioned ListView in Android

Sectioned ListView in Android

We wanted our iOS and Android apps to look as similar as possible while following UI design guidelines for each OS. You’ll notice that the Android calendar app, just like the iOS calendar app, uses a ListView with headers:

However, unlike the UITableView in iOS, these headers are not a built-in feature of the ListView class. You have to generate them manually! We searched online and found a number of solutions, but they were either too bulky or completely inelegant. So we set out to write our own.

The basic problem is that our data is stored in a tree, and the ListView expects a flat list. So we have to flatten our tree. Our model class stores the events in a SortedMap, where the keys are Date objects and the values are arrays of (custom) Event objects. (Note that in order for the flattened list to have a meaningful order, the tree must be implemented using a SortedMap.)

We want our headers to be the days, which are the keys in the tree. So they have to be present in the flattened list. The rows themselves will be Event objects.

For any given position in the flattened list, we have to know if it’s a header or an event, and we have to be able to access the related object in order to configure its cell. Also, we don’t want this index to take up much of the limited memory available on most Android devices.

Our solution was to first create a (very) simple class called RowPath that mimics NSIndexPath from iOS. Here’s the code:

private static class RowPath {
	private int section;
	private int row;
	public RowPath(int section, int row) {
		this.section = section;
		this.row = row;
	}
	public int getSection() { return section; }
	public int getRow() { return row; }
}

Next, each time our model’s tree changes, we rebuild a list of RowPath objects that serves as a flattened index of our tree. Here’s how:

private void rebuildRowPathIndex() {
	rowPathIndex = new ArrayList();
	for (int section=0;
			section < model.events().keySet().size();
			section++) {
		// rowPath indicating the header
		rowPathIndex.add(new RowPath(section, -1));
		// rowPaths indicating the events (actual rows)
		for (int row=0; row < model.events().get(model.events().sortedKeySet().get(section)).size(); row++) {
			rowPathIndex.add(new RowPath(section, row));
		}
	}
}

Finally, we have a few convenience methods for working with these RowPaths:

private RowPath getRowPath(int position) {
	RowPath result;
	try {
		result = rowPathIndex.get(position);
	} catch (ArrayIndexOutOfBoundsException e) {
		result = null;
	}
	return result;
}

private Event getEventAtRowPath(RowPath rowPath) {
	return model.events().get(model.events().sortedKeySet().get(rowPath.getSection())).get(rowPath.getRow());
}

Now we can use this code in a custom subclass of ListAdapter. Our ListAdapter will return two kinds of views:

private static final int SECTION_HEADER_VIEW_TYPE = 0;
private static final int ROW_VIEW_TYPE = 1;

public int getViewTypeCount() {
	return 2;
}

public int getItemViewType(int position) {
	RowPath rowPath = getRowPath(position);
	if (rowPath.getRow() == -1) {
		return SECTION_HEADER_VIEW_TYPE;
	}
	else {
		return ROW_VIEW_TYPE;
	}
}

Here’s how we actually configure the views:

public View getView(int position, View convertView, ViewGroup parent) {
	RowPath rowPath = getRowPath(position);
	if (rowPath.getRow() == -1) {
		return getSectionHeaderView(rowPath, convertView, parent);
	}
	else {
		return getRowView(rowPath, convertView, parent);
	}
}

private static final DateFormat sectionHeaderFormatter = DateFormat.getDateInstance(DateFormat.LONG);
private View getSectionHeaderView(RowPath rowPath, View convertView, ViewGroup parent) {

	TextView tView;
	try {
		tView = (TextView)convertView;
	} catch (ClassCastException e) {
		tView = null;
	}
	if (tView == null) {
		LayoutInflater inflater = context.getLayoutInflater();
		tView = (TextView)inflater.inflate(sectionHeaderResource, parent, false);
	}
	tView.setText(sectionHeaderFormatter.format(model.events().sortedKeySet().get(rowPath.getSection())));
	return tView;
}

private static final DateFormat timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT);
private View getRowView(RowPath rowPath, View convertView, ViewGroup parent) {
	Event event = getEventAtRowPath(rowPath);
	View rootView;
	if (convertView != null) {
		rootView = convertView;
	}
	else {
		LayoutInflater inflater = context.getLayoutInflater();
		rootView = inflater.inflate(rowResource, parent, false);
	}
	TextView titleView = (TextView)(rootView.findViewById(R.id.cal_cell_title_tv));
	TextView timeView = (TextView)(rootView.findViewById(R.id.cal_cell_time_tv));
	TextView locationView = (TextView)(rootView.findViewById(R.id.cal_cell_location_tv));
	titleView.setText(event.getTitle() == null ? context.getString(R.string.no_title) : event.getTitle());
	if (event.isAllDay()) {
		timeView.setText(context.getString(R.string.all_day));
	}
	else {
		timeView.setText(timeFormatter.format(event.getStartTime())
				+ " " + context.getString(R.string.cal_time_joiner) + " "
				+ timeFormatter.format(event.getEndTime()));
	}
	locationView.setText(event.getLocation() == null ? "" : event.getLocation());
	return rootView;
}

And that’s how we did it! Here’s a screenshot of our finished product with test data shown.

This is not a complete listing of our code, but it should be enough to give you a general idea of how to implement this. If anyone is interested in more details or a complete listing of our code, please drop us an email!

2 Replies to “Sectioned ListView in Android”

Leave a Reply

Your email address will not be published. Required fields are marked *