Home > android, ui > Adding group headers to ListView

Adding group headers to ListView

Let’s say we wanted to implement a ListView with group headers, where each group is defined by a certain data column having the same value. In other words, each time the data column has a new value (proceeeding from top to bottom), we want to have a special view that highlights that fact.

You can see an example of this in the default Android email application. Here is a screenshot that also highlights this feature:

I’ve highlighted (in green) the data values used for grouping: email message dates.

The ListView is backed up by a SQLite cursor.

First, let’s talk about two wrong ways to do this.

Wrong way number one:

Implement the group header view as a separate ListView item layout. What this means is that item numbers will “slide”, because these group headers will take their own slots within the item sequence.

Now if we did this, we’d need to write code to map item numbers between what’s in the ListView, and our data (the cursor).

To avoid this complication, we’re going to merge each group header with the layout of the first item in that group. There will be two types of list item layouts: one for items that start a new group, including views to highlight the group (blue in the screenshot above), and the “regular” list item layout for items that do not start a new group.

ListView actually lets us mix several layouts within the same list, and is capable of managing list item view layout recyclidng, taking view type for a particular list item into consideration.

Wrong way number two:

We could preprocess the data, determine where groups start, and take it from there.

This is bad for peformance reasons:

- First, we’d need to traverse the entire data set defined by our cursor, which can be pretty slow in itself (managing traversals limited by how far as the user scrolls is another piece of code that I’d like to avoid writing).

- Second, we’d need to mark new groups in the database. Flash memory writes can take a long time, so now we’d need to implement a background thread.

In short, preprocessing the data is complicated and slow. We’re going to avoid this by “looking back” at the previous data item when necessary, and comparing the message date in the current and previous data items, to see if the current item starts a new group.

Now, let’s look at the code

I’m going to use a pretty typical email message list view as an example, grouping messages by date.

First, we need to define two layouts, one for items that start a new group, and one for the items that don’t.

This is the “plain” item layout:

<RelativeLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="wrap_content">
  
	<TextView android:id="@+id/message_item_subject"
		android:layout_width="fill_parent"
		android:layout_height="wrap_content"
		android:textSize="18sp"/>
		
	<TextView android:id="@+id/message_item_from"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_subject"
			android:singleLine="true"
			android:textSize="16sp"/>

	<TextView android:id="@+id/message_item_when"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_from"
			android:singleLine="true"
			android:textSize="16sp"/>

	<!-- more views snipped -->

</RelativeLayout>

The layout for items that start a group adds a view at the top, to show the date common to all the messages within that particular group. The rest is the same:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="wrap_content">
  
	<TextView android:id="@+id/message_item_when_header"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:background="#ff4040f0"
			android:textColor="#ffffffff"
			android:textSize="22sp"/>
  
	<TextView android:id="@+id/message_item_subject"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_below="@id/message_item_when_header"
			android:textSize="18sp"/>
		
	<TextView android:id="@+id/message_item_from"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_subject"
			android:singleLine="true"
			android:textSize="16sp"/>

	<TextView android:id="@+id/message_item_when"
			android:layout_width="fill_parent"
			android:layout_height="wrap_content"
			android:layout_marginLeft="12dp"
			android:layout_below="@id/message_item_from"
			android:singleLine="true"
			android:textSize="16sp"/>

	<!-- more views snipped -->

</RelativeLayout>

And now for the message list adapter. We’re going to derive it from CursorAdapter, so we get automatic list updates whenever the underlying data changes.

We’re adding two methods to tell ListView that there are two types of list item layouts, and which list item uses which one (getViewTypeCount and getItemViewType).

The code that “looks back” at the previous data item and decides if the current item starts a new group is placed in its own method, isNewGroup. This method assumes that the position within the database cursor has already been set, and restores the position before returning.

Item view layouts are created in newView and their values are set in bindView, this is usual for subclass of CursorAdapter.

public class MessageListActivity extends Activity {

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

		mMessageListView = (ListView) findViewById(R.id.message_list);

		// Set list view click handler

		mMessageListView.setOnItemClickListener(new OnItemClickListener() {
			@Override
			public void onItemClick(AdapterView<?> parnet, View view, int position, long id) {
				// display an activiting show item details
			}
		});

		// Set list view adapter

		Cursor cursor = managedQuery();  // parameters snipped
		mAdapter = new MessageAdapter(this, cursor);
		mMessageListView.setAdapter(mAdapter);
	}

	private static final class MessageAdapter extends CursorAdapter {

		// We have two list item view types

		private static final int VIEW_TYPE_GROUP_START = 0;
		private static final int VIEW_TYPE_GROUP_CONT = 1;
		private static final int VIEW_TYPE_COUNT = 2;

		MessageAdapter(Context context, Cursor cursor) {
			super(context, cursor);

			// Get the layout inflater

			mInflater = LayoutInflater.from(context);

			// Get and cache column indices

			mColSubject = cursor.getColumnIndex(MailConstants.MESSAGE_SUBJECT);
			mColFrom = cursor.getColumnIndex(MailConstants.MESSAGE_FROM);
			mColWhen = cursor.getColumnIndex(MailConstants.MESSAGE_WHEN);
		}

		@Override
		public int getViewTypeCount() {
			return VIEW_TYPE_COUNT;
		}

		@Override
		public int getItemViewType(int position) {
			// There is always a group header for the first data item

			if (position == 0) {
				return VIEW_TYPE_GROUP_START;
			}

			// For other items, decide based on current data

			Cursor cursor = getCursor();
			cursor.moveToPosition(position);
			boolean newGroup = isNewGroup(cursor, position);

			// Check item grouping

			if (newGroup) {
				return VIEW_TYPE_GROUP_START;
			} else {
				return VIEW_TYPE_GROUP_CONT;
			}
		}

		@Override
		public View newView(Context context, Cursor cursor, ViewGroup parent) {

			int position = cursor.getPosition();
			int nViewType;

			if (position == 0) {
				// Group header for position 0

				nViewType = VIEW_TYPE_GROUP_START;
			} else {
				// For other positions, decide based on data

				boolean newGroup = isNewGroup(cursor, position);

				if (newGroup) {
					nViewType = VIEW_TYPE_GROUP_START;
				} else {
					nViewType = VIEW_TYPE_GROUP_CONT;
				}
			}

			View v;

			if (nViewType == VIEW_TYPE_GROUP_START) {
				// Inflate a layout to start a new group

				v = mInflater.inflate(R.layout.message_list_item_with_header, parent, false);

				// Ignore clicks on the list header

				View vHeader = v.findViewById(R.id.message_item_when_header);
				vHeader.setOnClickListener(new OnClickListener() {
					@Override
					public void onClick(View v) {
					}
				});
			} else {
				// Inflate a layout for "regular" items

				v = mInflater.inflate(R.layout.message_list_item, parent, false);
			}
			return v;
		}

		@Override
		public void bindView(View view, Context context, Cursor cursor) {
			TextView tv;

			tv = (TextView) view.findViewById(R.id.message_item_subject);
			tv.setText(cursor.getString(mColSubject));

			tv = (TextView) view.findViewById(R.id.message_item_from);
			tv.setText(cursor.getString(mColFrom));

			tv = (TextView) view.findViewById(R.id.message_item_when);
			Date d = new Date(cursor.getLong(mColWhen));
			tv.setText(gDateFormatDataItem.format(d));

			// If there is a group header, set its value to just the date

			tv = (TextView) view.findViewById(R.id.message_item_when_header);
			if (tv != null) {
				tv.setText(gDateFormatGroupItem.format(d));
			}
		}

		/**
		 * Determines whether the current data item (at the current position
		 * within the cursor) starts a new group, based on message date.
		 * 
		 * @param cursor
		 *            SQLite database cursor, the current data item position is
		 *            assumed to have been set by the caller
		 * @param position
		 *            The current data item's position within the cursor
		 * @return True if the current data item starts a new group
		 */

		private boolean isNewGroup(Cursor cursor, int position) {
			// Get date values for current and previous data items

			long nWhenThis = cursor.getLong(mColWhen);

			cursor.moveToPosition(position - 1);
			long nWhenPrev = cursor.getLong(mColWhen);

			// Restore cursor position

			cursor.moveToPosition(position);

			// Compare date values, ignore time values

			Calendar calThis = Calendar.getInstance();
			calThis.setTimeInMillis(nWhenThis);

			Calendar calPrev = Calendar.getInstance();
			calPrev.setTimeInMillis(nWhenPrev);

			int nDayThis = calThis.get(Calendar.DAY_OF_YEAR);
			int nDayPrev = calPrev.get(Calendar.DAY_OF_YEAR);

			if (nDayThis != nDayPrev || calThis.get(Calendar.YEAR) != calPrev.get(Calendar.YEAR)) {
				return true;
			}

			return false;
		}

		LayoutInflater mInflater;

		private int mColSubject;
		private int mColFrom;
		private int mColWhen;

		private static SimpleDateFormat gDateFormatDataItem = new SimpleDateFormat(
				"E, dd MMM yyyy HH:mm:ss");
		private static SimpleDateFormat gDateFormatGroupItem = new SimpleDateFormat("EEE, dd MMMM");
	}

	// End of MessageAdapter

	private ListView mMessageListView;
	private ListAdapter mAdapter;
}

Finishing touches

Some devices have a track pad that can be used to select list view items. The currently selected item is highlighted correctly, even for items that start a new group. This is probably because the message_item_when_header TextView (group header) has a background color specified for it:

Finally, since our activity processes clicks on list items (to launch an activity showing a detailed message view), we need to ignore clicks on the group header. This is done inside newView by setting a special, empty, click handler just for the group header TextView.

Here is the code again:

			if (nViewType == VIEW_TYPE_GROUP_START) {
				// Inflate a layout that starts a new group

				v = mInflater.inflate(R.layout.message_list_item_with_header, parent, false);

				// Ignore clicks on the group header

				View vHeader = v.findViewById(R.id.message_item_when_header);
				vHeader.setOnClickListener(new OnClickListener() {
					@Override
					public void onClick(View v) {
					}
				});
			} else {
				// regular item case snipped
			}

Performance

I profiled the code above while scrolling, and found that cursor.setPosition only took about 1.5% of total CPU time. Actually, the report was dominated by TextView.setText and SimpleDateFormat.format, each taking about equal amounts of time (apart from ListView code).

This makes sense, since we’re only moving the cursor to negihboring data rows, which should be cached (either in the framework, SQLite database, or – in the worst case – by the Linux kernel file system cache). If the user doesn’t scroll very far, then we don’t touch rows that don’t become visible.

And that’s about it. I hope this was usefu.

About these ads
Categories: android, ui
  1. Sakib
    December 19, 2013 at 1:59 pm

    My rows are duplicated, how to get rid of that? I tried both holder and tag holder.

    • Sakib
      December 21, 2013 at 12:31 pm

      Sakib :
      My rows are duplicated, how to get rid of that? I tried both holder and tag holder.

      Was actually a problem with my DB.

  2. Sakib
    December 18, 2013 at 12:15 pm

    Works like magic, thanks!!

  3. greg
    September 15, 2012 at 9:43 pm

    Why not use one layout and inside BindView, you can either show or hide a header ?

  4. June 25, 2012 at 12:22 pm

    One small concern with this solution is that the list item selector drawable will be drawn over the group header (my app have group header drawable as a thick line and transparent background above) (which is not desired)

    Is there any trick to overcome this?

    • June 25, 2012 at 1:52 pm

      Nothing off-hand, sorry. My headers are opaque, so I’ve not run into this.

  5. June 7, 2012 at 11:34 am

    very useful post, i didnt imagine i could do it so easy . I did using baseadpater and doing changes in getview function. Thanks a lot

  6. R
    January 16, 2012 at 2:27 pm

    This brilliant and thank you for the share.

  7. Nepami
    January 5, 2012 at 8:32 pm

    Nice article ! Great job !

  8. Alex Tang
    September 18, 2011 at 8:56 am

    Great Work, Thank you very much

  9. Marc
    August 27, 2011 at 11:49 pm

    I wonder how I can achieve this with an ArrayAdapter? I can’t use a CursorAdapter because my data does not come from a database. Do I have to override getView() to set the layout type (class ArrayAdapter doesn’t know newView() and bindView())?

    • August 28, 2011 at 12:11 am

      You don’t have to use CursorAdapter to add headers like this.

      Just don’t use ArrayAdapter – that’s like a chldren’s bicycle with training wheels.

      The newView / bindView methods in CursorAdapter correspond to getView in, let’s say, a subclass of BaseAdapter. The basic technique is still the same – look back one item position to know if you should start a new group, or continue an existing one.

      • Marc
        September 1, 2011 at 10:36 pm

        So can you please make a proposal what adapter would be best choice for me? I’m not yet very familiar with those adapter types in Android…

  10. May 16, 2011 at 9:24 pm

    Just what I needed! I’ve been looking for this kind of solution for ages. Thx.

  11. dennis
    April 24, 2011 at 11:44 pm

    Great explanation. Thank you for taking the time to put this up.

  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

Follow

Get every new post delivered to your Inbox.

Join 100 other followers