ListView is commonly used to display a set of data in a list. For example a list of emails, messages, tasks or contacts. I’ve written an article on how to work with lists in android before, which you might want to read.
In this article we’ll discuss how to break a list of our phone contacts into sections. So instead of displaying our contacts in a random order, we’ll sort them alphabetically and group them into chunks with headers representing each alphabet as in the native “Contacts” application. Hence, we’ll have something like “A -> Adam, Anne | B -> Ben, Brad …”.
What's the one thing every developer wants? More screens! Enhance your coding experience with an external monitor to increase screen real estate.
We’ll discuss 2 approaches.
First Approach: Toggle Visibility of Separator View inside rows
In this method, what we’ll do is place a View, which will be a TextView
in this case inside the row’s layout file. In the getView()
method that gets called by the Adapter to render our views inside the ListView
, we’ll check if the current item belongs to a different group than the previous item or if the current item is the first item of the entire group or not. If yes then we can simply show up the separator by calling the setVisibility()
method on the View
object and pass it the View.VISIBLE
argument. On the other hand, if we pass the View.GONE
argument like setVisiblity(View.GONE)
then it’ll simply make the view invisibile and not take up any space.
So we’ll fetch all the contacts from our phone book using the Contacts ContentProvider and section them alphabetically. Here’s how the layout will look like:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="10dp"> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="New Text" android:id="@+id/separator" android:textSize="14sp" android:textStyle="bold" style="?android:listSeparatorTextViewStyle" /> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="New Text" android:id="@+id/contact_name" android:textSize="18sp" android:layout_marginTop="10dip" android:paddingLeft="8dip" android:paddingRight="8dip" /> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="New Text" android:id="@+id/phone_number" android:textColor="#ff8a8a8c" android:paddingLeft="8dip" android:paddingRight="8dip" /> </LinearLayout>
If you notice, the first TextView
is the one that’ll be used as a separator. It is visible by default but based on certain conditions (that we’ll look into in a bit) we’ll hide it from the user interface.
Here’s our entire Activity
code (that contains the Adapter
as an inner class):
package com.pycitup.pyc; import android.app.Activity; import android.content.Context; import android.database.Cursor; import android.os.Bundle; import android.provider.ContactsContract; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ListView; import android.widget.TextView; public class ContactsActivity extends Activity { String[] mProjection; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_contacts); mProjection = new String[] { ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; final Cursor cursor = getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, mProjection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); CustomAdapter adapter = new CustomAdapter(this, cursor); // Create the list view and bind the adapter ListView listView = (ListView) findViewById(R.id.listview); listView.setAdapter(adapter); } public class CustomAdapter extends BaseAdapter { private Context mContext; private Cursor mCursor; // State of the row that needs to show separator private static final int SECTIONED_STATE = 1; // State of the row that need not show separator private static final int REGULAR_STATE = 2; // Cache row states based on positions private int[] mRowStates; public CustomAdapter(Context context, Cursor cursor) { mContext = context; mCursor = cursor; mRowStates = new int[getCount()]; } @Override public int getCount() { return mCursor.getCount(); } @Override public Object getItem(int position) { return null; } @Override public long getItemId(int position) { return 0; } @Override public View getView(int position, View convertView, ViewGroup parent) { View view; boolean showSeparator = false; mCursor.moveToPosition(position); if (convertView == null) { LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); view = inflater.inflate(R.layout.contact_item, null); } else { view = convertView; } // Set contact name and number TextView contactNameView = (TextView) view.findViewById(R.id.contact_name); TextView phoneNumberView = (TextView) view.findViewById(R.id.phone_number); String name = mCursor.getString( mCursor.getColumnIndex(mProjection[0]) ); String number = mCursor.getString( mCursor.getColumnIndex(mProjection[1]) ); contactNameView.setText( name ); phoneNumberView.setText( number ); // Show separator ? switch (mRowStates[position]) { case SECTIONED_STATE: showSeparator = true; break; case REGULAR_STATE: showSeparator = false; break; default: if (position == 0) { showSeparator = true; } else { mCursor.moveToPosition(position - 1); String previousName = mCursor.getString(mCursor.getColumnIndex(mProjection[0])); char[] previousNameArray = previousName.toCharArray(); char[] nameArray = name.toCharArray(); if (nameArray[0] != previousNameArray[0]) { showSeparator = true; } mCursor.moveToPosition(position); } // Cache it mRowStates[position] = showSeparator ? SECTIONED_STATE : REGULAR_STATE; break; } TextView separatorView = (TextView) view.findViewById(R.id.separator); if (showSeparator) { separatorView.setText(name.toCharArray(), 0, 1); separatorView.setVisibility(View.VISIBLE); } else { view.findViewById(R.id.separator).setVisibility(View.GONE); } return view; } } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.contacts, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. int id = item.getItemId(); if (id == R.id.action_settings) { return true; } return super.onOptionsItemSelected(item); } }
That’s quite a bit of code but we’ll only focus on the portions that are essential to us. So the part that fetches all the contacts is this one:
mProjection = new String[] { ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; final Cursor cursor = getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, mProjection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" );
We use the Contacts Provider to fetch all the contacts names and phone numbers in ascending order. You can get to know more about then query syntax and you can easily relate it to SQL here.
Now on to the real portion that does the job of toggling the separator’s visiblity. It’s all in the getView()
method:
public View getView(int position, View convertView, ViewGroup parent) { View view; boolean showSeparator = false; mCursor.moveToPosition(position); if (convertView == null) { LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); view = inflater.inflate(R.layout.contact_item, null); } else { view = convertView; } // Set contact name and number TextView contactNameView = (TextView) view.findViewById(R.id.contact_name); TextView phoneNumberView = (TextView) view.findViewById(R.id.phone_number); String name = mCursor.getString( mCursor.getColumnIndex(mProjection[0]) ); String number = mCursor.getString( mCursor.getColumnIndex(mProjection[1]) ); contactNameView.setText( name ); phoneNumberView.setText( number ); // Show separator ? switch (mRowStates[position]) { case SECTIONED_STATE: showSeparator = true; break; case REGULAR_STATE: showSeparator = false; break; default: if (position == 0) { showSeparator = true; } else { mCursor.moveToPosition(position - 1); String previousName = mCursor.getString(mCursor.getColumnIndex(mProjection[0])); char[] previousNameArray = previousName.toCharArray(); char[] nameArray = name.toCharArray(); if (nameArray[0] != previousNameArray[0]) { showSeparator = true; } mCursor.moveToPosition(position); } // Cache it mRowStates[position] = showSeparator ? SECTIONED_STATE : REGULAR_STATE; break; } TextView separatorView = (TextView) view.findViewById(R.id.separator); if (showSeparator) { separatorView.setText(name.toCharArray(), 0, 1); separatorView.setVisibility(View.VISIBLE); } else { view.findViewById(R.id.separator).setVisibility(View.GONE); } return view; }
We start off by moving the cursor to the appropriate position by making this call mCursor.moveToPosition(position);
and then based on whether the convertView
is cached or not, we inflate the R.layout.contact_item
layout file. Next, we set the contact name and number in the appropriate views and then hop on to the real part of checking where we show or hide the separator. The default
block inside the switch
block is where the real checking is done:
default: if (position == 0) { showSeparator = true; } else { mCursor.moveToPosition(position - 1); String previousName = mCursor.getString(mCursor.getColumnIndex(mProjection[0])); char[] previousNameArray = previousName.toCharArray(); char[] nameArray = name.toCharArray(); if (nameArray[0] != previousNameArray[0]) { showSeparator = true; } mCursor.moveToPosition(position); } // Cache it mRowStates[position] = showSeparator ? SECTIONED_STATE : REGULAR_STATE; break;
If the position
is 0, that means that it’s the first item then we show the separator. If not, then we move to the previous item in the cursor and get the name of the contact. Then we convert the previous name fetched and the current name to char[]
arrays to do a check on the first character, if they’re same or not. If not then we must show the separator. Finally, it is important to move back to the original position and ofcourse cache the state in mRowStates
.
When we’re done with all the hard work, we finally use the showSeparator
flag to hide/unhide the separator:
if (showSeparator) { separatorView.setText(name.toCharArray(), 0, 1); separatorView.setVisibility(View.VISIBLE); } else { view.findViewById(R.id.separator).setVisibility(View.GONE); }
There’s one UX issue with this method which is separators are clickable. Atleast the default highlighting on rows on tapping can be prevented by setting android:clickable="true"
on the separator view (as it intercepts touch calls to the ListView
), that too only when the separator is tapped on but not the sibling views in the same row.
Also when I think about 90-95% of the rows having a hidden view (separator) I feel like there’s more memory being used that implies wastage. That said, this method has the advantage of sectioning in realtime or “on the fly”. This is especially helpful when working with cursors (querying a Content Provider) and not Arrays which has the data stored (that can also be manipulated easily). Another benefit of this approach is the direct mapping of the row’s position to the position of the data in the cursor.
Second Approach: Using a different View for Separator
The other approach to sectioning is to use an entirely different view for the separator. So let’s create a new layout file at /layout/contact_section_header.xml
:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="New Text" android:id="@+id/separator" android:textSize="14sp" android:textStyle="bold" style="?android:listSeparatorTextViewStyle" /> </LinearLayout>
And this is how our new /layout/contact_item.xml
will look like:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="10dp"> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="New Text" android:id="@+id/contact_name" android:textSize="18sp" android:layout_marginTop="10dip" android:paddingLeft="8dip" android:paddingRight="8dip" /> <TextView android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="New Text" android:id="@+id/phone_number" android:textColor="#ff8a8a8c" android:paddingLeft="8dip" android:paddingRight="8dip" /> </LinearLayout>
Our onCreate()
method will also change to some extent:
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_contacts); mProjection = new String[] { ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.NUMBER }; final Cursor cursor = getContentResolver().query( ContactsContract.CommonDataKinds.Phone.CONTENT_URI, mProjection, null, null, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" ); int nameIndex = cursor.getColumnIndex(mProjection[0]); int numberIndex = cursor.getColumnIndex(mProjection[1]); ArrayList contacts = new ArrayList(); int position = 0; boolean isSeparator = false; while(cursor.moveToNext()) { isSeparator = false; String name = cursor.getString(nameIndex); String number = cursor.getString(numberIndex); char[] nameArray; // If it is the first item then need a separator if (position == 0) { isSeparator = true; nameArray = name.toCharArray(); } else { // Move to previous cursor.moveToPrevious(); // Get the previous contact's name String previousName = cursor.getString(nameIndex); // Convert the previous and current contact names // into char arrays char[] previousNameArray = previousName.toCharArray(); nameArray = name.toCharArray(); // Compare the first character of previous and current contact names if (nameArray[0] != previousNameArray[0]) { isSeparator = true; } // Don't forget to move to next // which is basically the current item cursor.moveToNext(); } // Need a separator? Then create a Contact // object and save it's name as the section // header while pass null as the phone number if (isSeparator) { Contact contact = new Contact(String.valueOf(nameArray[0]), null, isSeparator); contacts.add( contact ); } // Create a Contact object to store the name/number details Contact contact = new Contact(name, number, false); contacts.add( contact ); position++; } // Creating our custom adapter CustomAdapter adapter = new CustomAdapter(this, contacts); // Create the list view and bind the adapter ListView listView = (ListView) findViewById(R.id.listview); listView.setAdapter(adapter); }
So we get the previous contact name and compare its first character with the first character of the current contact name. If they’re different then create a Contact
object to which the third argument passed is true
while null
is passed as the phone number. The name of the contact is set to the first character.
Regardless of the check, one Contact
object is always created for the current contact. All these objects are stored in an ArrayList
which is then used by our custom adapter.
Let’s quickly look at our Contact
class first which is fairly straightforward:
public class Contact { public String mName; public String mNumber; public boolean mIsSeparator; public Contact(String name, String number, boolean isSeparator) { mName = name; mNumber = number; mIsSeparator = isSeparator; } public void setName(String name) { mName = name; } public void setNumber(String number) { mNumber = number; } public void setIsSection(boolean isSection) { mIsSeparator = isSection; } }
The convention is to define getter methods as well, but to keep things short we’ll access the public properties directly from wherever it is required.
Finally, here’s our CustomAdapter
class with umpteen new things that we haven’t covered before.
public class CustomAdapter extends BaseAdapter { private Context mContext; private ArrayList<Contact> mList; // View Type for Separators private static final int ITEM_VIEW_TYPE_SEPARATOR = 0; // View Type for Regular rows private static final int ITEM_VIEW_TYPE_REGULAR = 1; // Types of Views that need to be handled // -- Separators and Regular rows -- private static final int ITEM_VIEW_TYPE_COUNT = 2; public CustomAdapter(Context context, ArrayList list) { mContext = context; mList = list; } @Override public int getCount() { return mList.size(); } @Override public Object getItem(int position) { return null; } @Override public long getItemId(int position) { return 0; } @Override public int getViewTypeCount() { return ITEM_VIEW_TYPE_COUNT; } @Override public int getItemViewType(int position) { boolean isSection = mList.get(position).mIsSeparator; if (isSection) { return ITEM_VIEW_TYPE_SEPARATOR; } else { return ITEM_VIEW_TYPE_REGULAR; } } @Override public boolean isEnabled(int position) { return getItemViewType(position) != ITEM_VIEW_TYPE_SEPARATOR; } @Override public View getView(int position, View convertView, ViewGroup parent) { View view; Contact contact = mList.get(position); int itemViewType = getItemViewType(position); if (convertView == null) { LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); if (itemViewType == ITEM_VIEW_TYPE_SEPARATOR) { // If its a section ? view = inflater.inflate(R.layout.contact_section_header, null); } else { // Regular row view = inflater.inflate(R.layout.contact_item, null); } } else { view = convertView; } if (itemViewType == ITEM_VIEW_TYPE_SEPARATOR) { // If separator TextView separatorView = (TextView) view.findViewById(R.id.separator); separatorView.setText(contact.mName); } else { // If regular // Set contact name and number TextView contactNameView = (TextView) view.findViewById(R.id.contact_name); TextView phoneNumberView = (TextView) view.findViewById(R.id.phone_number); contactNameView.setText( contact.mName ); phoneNumberView.setText( contact.mNumber ); } return view; } }
Three new methods have been introduced (overridden) here:
-
getViewTypeCount()
– This method should return the number of types of Views that will be created and returned by thegetView()
method. -
getItemViewType()
– This method should return an integer representing the type of View that will be created bygetView()
for the item at the specified position. In our case we’ve defined 2 constantsITEM_VIEW_TYPE_SEPARATOR = 0
andITEM_VIEW_TYPE_REGULAR = 1
that is returned by this method. So basically this method must return and integer between0
andgetViewTypeCount() - 1
. -
isEnabled
– If the item at the specified position is a separator (a non-selectable and non-clickable item) then this method should returntrue
, elsefalse
.
Finally, if you go through the contents of the getView()
method then you’ll notice that we’re basically inflating and setting content (texts) for different layout files and views based on the view type returned by getItemViewType()
method.
The best part about this approach is that it is very easy to comprehend and at the same time can manage multiple types of items in the ListView
.
Note: Let’s say that our section is something like this – “s1 -> r1, r2, r3 | s2 -> r4, r5” – where sN is a separator and rN is a row, the position of r4 will actually be 6th (5th index) in the list and not 4th (3rd index). Thankfully since we have the data for both separators and rows stored in the ArrayList
, when the user taps or does some sort of interaction with the 6th item on the list (r4) the position passed to event listeners will match with that of the data structure (ArrayList
).
That’s All!
That’s all folks! I hope the 2 approaches explained in order to divide your list view into groups on android proves to be helpful for your projects. If you’ve any questions then let’s discuss in the comments below.
This was exactly what I need!
Thanks !
Grtz