Tuesday, August 21, 2012

Section Index for Android

Recently I'd asked, if it's possible to have iPhone's Section Index in Android.
I thougt it wouldn't be a problem and said yes. But it's wasn't so simple. I just hope, I haven't reinvented the wheel. I'll call it SideIndex.

Section Index for Android

This is xml-layout (main.xml) - a list view and a linear layout for side index within another linear layout:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<LinearLayout
android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<ListView
android:id="@+id/ListView01"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1">

</ListView>
<LinearLayout
android:orientation="vertical"
android:background="#FFF"
android:id="@+id/sideIndex"
android:layout_width="40dip"
android:layout_height="fill_parent"
android:gravity="center_horizontal">
</LinearLayout>
</LinearLayout>
</LinearLayout>
In the activity class I define some member variables and an array with values for the ListView. This array isn't sorted.
public class SideIndex extends Activity
{
private GestureDetector mGestureDetector;

// x and y coordinates within our side index
private static float sideIndexX;
private static float sideIndexY;

// height of side index
private int sideIndexHeight;

// number of items in the side index
private int indexListSize;

// list with items for side index
private ArrayList<Object[]> indexList = null;

// an array with countries to display in the list
static String[] COUNTRIES = new String[]
{
"East Timor", "Ecuador", "Egypt", "El Salvador", "Equatorial Guinea", "Eritrea",
"Estonia", "Ethiopia", "Faeroe Islands", "Falkland Islands", "Fiji", "Finland",
"Afghanistan", "Albania", "Algeria", "American Samoa", "Andorra",
"Angola", "Anguilla", "Antarctica", "Antigua and Barbuda", "Argentina",
"Armenia", "Aruba", "Australia", "Austria", "Azerbaijan",
"Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium",
"Monaco", "Mongolia", "Montserrat", "Morocco", "Mozambique", "Myanmar", "Namibia",
"Nauru", "Nepal", "Netherlands", "Netherlands Antilles", "New Caledonia", "New Zealand",
"Guyana", "Haiti", "Heard Island and McDonald Islands", "Honduras", "Hong Kong", "Hungary",
"Iceland", "India", "Indonesia", "Iran", "Iraq", "Ireland", "Israel", "Italy", "Jamaica",
"Japan", "Jordan", "Kazakhstan", "Kenya", "Kiribati", "Kuwait", "Kyrgyzstan", "Laos",
"Latvia", "Lebanon", "Lesotho", "Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg",
"Nicaragua", "Niger", "Nigeria", "Niue", "Norfolk Island", "North Korea", "Northern Marianas",
"Norway", "Oman", "Pakistan", "Palau", "Panama", "Papua New Guinea", "Paraguay", "Peru",
"Philippines", "Pitcairn Islands", "Poland", "Portugal", "Puerto Rico", "Qatar",
"French Southern Territories", "Gabon", "Georgia", "Germany", "Ghana", "Gibraltar",
"Greece", "Greenland", "Grenada", "Guadeloupe", "Guam", "Guatemala", "Guinea", "Guinea-Bissau",
"Martinique", "Mauritania", "Mauritius", "Mayotte", "Mexico", "Micronesia", "Moldova",
"Bosnia and Herzegovina", "Botswana", "Bouvet Island", "Brazil", "British Indian Ocean Territory",
"Saint Vincent and the Grenadines", "Samoa", "San Marino", "Saudi Arabia", "Senegal",
"Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands",
"Somalia", "South Africa", "South Georgia and the South Sandwich Islands", "South Korea",
"Spain", "Sri Lanka", "Sudan", "Suriname", "Svalbard and Jan Mayen", "Swaziland", "Sweden",
"Switzerland", "Syria", "Taiwan", "Tajikistan", "Tanzania", "Thailand", "The Bahamas",
"The Gambia", "Togo", "Tokelau", "Tonga", "Trinidad and Tobago", "Tunisia", "Turkey",
"Turkmenistan", "Turks and Caicos Islands", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates",
"United Kingdom", "United States", "United States Minor Outlying Islands", "Uruguay", "Uzbekistan",
"Vanuatu", "Vatican City", "Venezuela", "Vietnam", "Virgin Islands", "Wallis and Futuna",
"Western Sahara", "British Virgin Islands", "Brunei", "Bulgaria", "Burkina Faso", "Burundi",
"Cote d'Ivoire", "Cambodia", "Cameroon", "Canada", "Cape Verde",
"Cayman Islands", "Central African Republic", "Chad", "Chile", "China",
"Reunion", "Romania", "Russia", "Rwanda", "Sqo Tome and Principe", "Saint Helena",
"Saint Kitts and Nevis", "Saint Lucia", "Saint Pierre and Miquelon",
"Belize", "Benin", "Bermuda", "Bhutan", "Bolivia",
"Christmas Island", "Cocos (Keeling) Islands", "Colombia", "Comoros", "Congo",
"Cook Islands", "Costa Rica", "Croatia", "Cuba", "Cyprus", "Czech Republic",
"Democratic Republic of the Congo", "Denmark", "Djibouti", "Dominica", "Dominican Republic",
"Former Yugoslav Republic of Macedonia", "France", "French Guiana", "French Polynesia",
"Macau", "Madagascar", "Malawi", "Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands",
"Yemen", "Yugoslavia", "Zambia", "Zimbabwe"};
// ...
}
Some initial methods:
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.main);

// don't forget to sort our array (in case it's not sorted)
Arrays.sort(COUNTRIES);

final ListView lv1 = (ListView) findViewById(R.id.ListView01);
lv1.setAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, COUNTRIES));
mGestureDetector = new GestureDetector(this, new SideIndexGestureListener());
}

@Override
public boolean onTouchEvent(MotionEvent event)
{
if (mGestureDetector.onTouchEvent(event))
{
return true;
} else
{
return false;
}
}
The we'll create for given array a list with items (first letters). For every letter we'll store the letter itself, the start and end position of items for this letter.

private ArrayList<Object[]> createIndex(String[] strArr)
{
ArrayList<Object[]> tmpIndexList = new ArrayList<Object[]>();
Object[] tmpIndexItem = null;

int tmpPos = 0;
String tmpLetter = "";
String currentLetter = null;
String strItem = null;

for (int j = 0; j < strArr.length; j++)
{
strItem = strArr[j];
currentLetter = strItem.substring(0, 1);

// every time new letters comes
// save it to index list
if (!currentLetter.equals(tmpLetter))
{
tmpIndexItem = new Object[3];
tmpIndexItem[0] = tmpLetter;
tmpIndexItem[1] = tmpPos - 1;
tmpIndexItem[2] = j - 1;

tmpLetter = currentLetter;
tmpPos = j + 1;

tmpIndexList.add(tmpIndexItem);
}
}

// save also last letter
tmpIndexItem = new Object[3];
tmpIndexItem[0] = tmpLetter;
tmpIndexItem[1] = tmpPos - 1;
tmpIndexItem[2] = strArr.length - 1;
tmpIndexList.add(tmpIndexItem);

// and remove first temporary empty entry
if (tmpIndexList != null && tmpIndexList.size() > 0)
{
tmpIndexList.remove(0);
}

return tmpIndexList;
}
You can imagine, that not all item from side index could be displayed. If they are too much, only every m-th will be shown. I said, that the font size of every item should be minimum 20. Hence we could compute maximal number of items for this font size in the list. The problem is, you should know the height of the side index. If you'll try to get it in onCreate-methode, you'll get 0 back. Solution for it is to call getHeight in onWindowFocusChanged:
private ArrayList<Object[]> createIndex(String[] strArr)
{
ArrayList<Object[]> tmpIndexList = new ArrayList<Object[]>();
Object[] tmpIndexItem = null;

int tmpPos = 0;
String tmpLetter = "";
String currentLetter = null;
String strItem = null;

for (int j = 0; j < strArr.length; j++)
{
strItem = strArr[j];
currentLetter = strItem.substring(0, 1);

// every time new letters comes
// save it to index list
if (!currentLetter.equals(tmpLetter))
{
tmpIndexItem = new Object[3];
tmpIndexItem[0] = tmpLetter;
tmpIndexItem[1] = tmpPos - 1;
tmpIndexItem[2] = j - 1;

tmpLetter = currentLetter;
tmpPos = j + 1;

tmpIndexList.add(tmpIndexItem);
}
}

// save also last letter
tmpIndexItem = new Object[3];
tmpIndexItem[0] = tmpLetter;
tmpIndexItem[1] = tmpPos - 1;
tmpIndexItem[2] = strArr.length - 1;
tmpIndexList.add(tmpIndexItem);

// and remove first temporary empty entry
if (tmpIndexList != null && tmpIndexList.size() > 0)
{
tmpIndexList.remove(0);
}

return tmpIndexList;
}

Another problem was to implement posibility to scroll within side index. Touch events for linear layout aren't enough for that. So we have to implement a SimpleOnGestureListener:
private ArrayList<Object[]> createIndex(String[] strArr)
{
ArrayList<Object[]> tmpIndexList = new ArrayList<Object[]>();
Object[] tmpIndexItem = null;

int tmpPos = 0;
String tmpLetter = "";
String currentLetter = null;
String strItem = null;

for (int j = 0; j < strArr.length; j++)
{
strItem = strArr[j];
currentLetter = strItem.substring(0, 1);

// every time new letters comes
// save it to index list
if (!currentLetter.equals(tmpLetter))
{
tmpIndexItem = new Object[3];
tmpIndexItem[0] = tmpLetter;
tmpIndexItem[1] = tmpPos - 1;
tmpIndexItem[2] = j - 1;

tmpLetter = currentLetter;
tmpPos = j + 1;

tmpIndexList.add(tmpIndexItem);
}
}

// save also last letter
tmpIndexItem = new Object[3];
tmpIndexItem[0] = tmpLetter;
tmpIndexItem[1] = tmpPos - 1;
tmpIndexItem[2] = strArr.length - 1;
tmpIndexList.add(tmpIndexItem);

// and remove first temporary empty entry
if (tmpIndexList != null && tmpIndexList.size() > 0)
{
tmpIndexList.remove(0);
}

return tmpIndexList;
}
It's another important part of implementation. We compute here for every position a right item in the country list. Side index items are uniformly distributed. But there exist different number of countries for every letter. This should be kept in mind.

I must say, I can not really explain, what have I done. I've done it intuitive. And it works.
public void displayListItem()
{
// compute number of pixels for every side index item
double pixelPerIndexItem = (double) sideIndexHeight / indexListSize;

// compute the item index for given event position belongs to
int itemPosition = (int) (sideIndexY / pixelPerIndexItem);

// compute minimal position for the item in the list
int minPosition = (int) (itemPosition * pixelPerIndexItem);

// get the item (we can do it since we know item index)
Object[] indexItem = indexList.get(itemPosition);

// and compute the proper item in the country list
int indexMin = Integer.parseInt(indexItem[1].toString());
int indexMax = Integer.parseInt(indexItem[2].toString());
int indexDelta = Math.max(1, indexMax - indexMin);

double pixelPerSubitem = pixelPerIndexItem / indexDelta;
int subitemPosition = (int) (indexMin + (sideIndexY - minPosition) / pixelPerSubitem);

ListView listView = (ListView) findViewById(R.id.ListView01);
listView.setSelection(subitemPosition);
}
So, it works. I hope, it will work you as well.

I'll be thankfull for every tip of enhancement.

UPD:
Because of problem with SyntaxHiglighter I've added my project sources. You can download them here