Introduction
A recurring question I see in the Quick Answers section, with an "Android" tag, is something akin to "How can one spinner change its value(s) based on the selection of another spinner?" Hmmm...
I made a "sales tax" app many years ago that processed my state's online tax tables. It put all of the counties in one spinner control and all of the towns in another spinner control. With 77 counties and hundreds of towns, that's a bunch of combinations. Short of memorizing all towns in a given county, or all counties containing a given town, I made each spinner control's contents respond to the selection in the other. Once both had a selection, and a price entered, the total price and all sales taxes (state, county, town) would be shown. It worked rather nicely, and the data structure to hold it all was none too complicated.
Given that, I thought an article, much simpler than what I described above, would be a welcome addition. Of the aforementioned questions, each seemed to be procuring its data from different places. One would store the data in the project's strings.xml file, and another would be storing the data in a SQLite database. In this article, I'll show three different ways of storing the data, how to extract from each, and how to respond to a spinner control's selection.
Almost anything could be used to populate the two spinner controls: automobile makes and models, sports teams and players, countries and languages, states and ZIP codes or area codes, etc. For this exercise, I opted for states and districts from India. For no other reason than there was a lot of data, and the owners of the questions, all seemed to come from there, I got the data from here. Let's get started!
Hash
A HashMap
implements the Map
interface and stores its data as key-value pairings. This makes a perfect storage medium for a state as the key and its list of districts as the value. This gives us something like:
HashMap<String, ArrayList<String>> m_hashStates = new HashMap<String, ArrayList<String>>(36);
I chose to preallocate it because I knew ahead of time how many states there are. For something so small, this may not make any noticeable difference.
Populating this HashMap
is straightforward enough, and since this is done many times, I wrapped it up in a function, like:
private ArrayList<String> addState( String strName )
{
ArrayList<String> arrDistricts = new ArrayList<String>();
try
{
m_hashStates.put(strName, arrDistricts);
}
catch(Exception e)
{
Log.e("Test2", "Error adding state: " + e.getMessage());
}
return arrDistricts;
}
Note the empty array that is added to the mapping and returned to the caller. So, to add a state and its districts to the HashMap
, you would simply call that function, like:
ArrayList<String> arrDistricts = addState("Goa");
arrDistricts.add("North Goa");
arrDistricts.add("South Goa");
...
arrDistricts = addState("Tripura");
arrDistricts.add("Dhalai");
arrDistricts.add("Gomati");
arrDistricts.add("Khowai");
arrDistricts.add("North Tripura");
arrDistricts.add("Sepahijala");
arrDistricts.add("South Tripura");
arrDistricts.add("Unokoti");
arrDistricts.add("West Tripura");
I opted not to store these string
s in the strings.xml file because they neither needed to be translated nor formatted.
After everything is loaded into the HashMap
, we need to add its keys (the name of the states) to the adapter that belongs to the spinner control. Luckily, ArrayAdapter
, ArrayList
, and HashMap
all provide constructors and methods that tie together nicely to accomplish this:
m_adapterStates.addAll(new ArrayList(m_hashStates.keySet()));
Normally, when adding items to an ArrayAdapter
object, we would call its notifyDataSetChanged()
method so that the attached list will refresh itself. That's not necessary in this case as addAll()
does that for us.
The last thing we need to do is respond to a selection change. This is done by calling the spinner's setOnItemSelectedListener()
method and providing an onItemSelected()
callback. The index of the selected item in the adapter is in the position
variable. With that value, we can get the name of the selected state, which is the key used in the HashMap
. We then can get the list of associated districts and update its spinner control. This looks like:
spinStates.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener()
{
@Override
public void onItemSelected( AdapterView<?> parent, View view, int position, long id )
{
String strState = (String) spinStates.getItemAtPosition(position);
ArrayList<String> arrDistricts = m_hashStates.get(strState);
if (arrDistricts.size() == 0)
arrDistricts.add("No districts found for the selected state");
else
Collections.sort(arrDistricts);
adapterDistricts.clear();
adapterDistricts.addAll(arrDistricts);
}
}
That indeed is as simple as it gets when it comes to loading a spinner control and then having its selection govern what gets displayed in another spinner control. I've seen a lot of examples of others trying to implement this but with a ton more (convoluted) code. Don't make things any harder than they have to be! Let's move on to a slightly different example.
JSON
It seems like mentioning anything these days with the word Java in it, especially JavaScript, is an automatic cue for an argument (much like saying you used to, or still do, code in [Visual]Basic, or eat at McDonald's). Everyone has their opinion about why it should or shouldn't be used, and for the most part, they're all correct. For me, if a tool does what it's supposed to do and meets all of the project's requirements, then it's the correct tool for the job. In summary, use the storage mechanism (e.g., JSON, XML) that you are familiar and comfortable with.
In the previous section, the state and district data are implemented as string literals right in the code itself. In this section, we'll move the data into a separate JSON file where it will get processed, and then stored in a HashMap
like before. Here is a snippet of what that file looks like:
{
"Mizoram": [
"Aizawl",
"Champhai",
"Kolasib",
"Lawngtlai",
"Lunglei",
"Mamit",
"Saiha",
"Serchhip"
],
"Nagaland": [
"Dimapur",
"Kiphire",
"Kohima",
"Longleng",
"Mokokchung",
"Mon",
"Peren",
"Phek",
"Tuensang",
"Wokha",
"Zunheboto"
],
...
}
In our example, there are 36 of these key-value pairs, with the keys being the names of the states, and the values being an array of that state's districts. This file is going to be stored in the project's assets folder, so opening that file and reading its contents into a JSONObject
object (that just sounds wrong when verbalized) looks like:
String strLine;
StringBuilder builder = new StringBuilder("");
InputStream is = getAssets().open("states_districts.json");
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
while ((strLine = reader.readLine()) != null)
builder.append(strLine);
reader.close();
is.close();
JSONObject obj = new JSONObject(builder.toString());
At this point, obj
contains everything we need to start pulling out the keys and their values. Similar to what we did earlier in the Hash section, we need to iterate each key (i.e., state) and for each one found, add its list of values (i.e., districts) to an ArrayList
container. The JSONObject
class has a handy method, keys()
, that will give us just the keys to iterate. We can then use the getJSONArray()
method to get the values belonging to each key. The code to do this looks like:
Iterator<String> keys = obj.keys();
while (keys.hasNext())
{
String sName = keys.next();
JSONArray arr = obj.getJSONArray(sName);
ArrayList<String> arrDistricts = new ArrayList<String>(arr.length());
for (int y = 0; y < arr.length(); y++)
arrDistricts.add(arr.getString(y));
Collections.sort(arrDistricts);
m_hashStates.put(sName, arrDistricts);
}
In response to a selection change in the state's spinner control, the onItemSelected()
method is identical to the one shown above so I won't duplicate it here for brevity reasons.
SQLite
The last method to discuss involves storing the data in a SQLite database. This approach has both similarities and differences compared to the previous methods. The states and districts being added to the database originate as string literals, and in lieu of a HashMap
to hold the names of the states and an array of district names for each, we'll simply store that information in two SQLite tables. An ArrayList
will again be used to hold the names of the states and be tied to the state's adapter. A simple query on the database will retrieve the district names.
The database has two tables, each with an _ID
and a NAME
field. The _ID
field in the states
table will be the primary key and is autoincrementing. The _ID
field in the districts
table will be the foreign key with constraints enabled.
To help manage the database, an extension of SQLiteOpenHelper
is needed. That class in its entirety looks like:
private class DatabaseHelper extends SQLiteOpenHelper
{
private static final String DATABASE_NAME = "junk.db";
private static final int DATABASE_VERSION = 1;
public static final String STATES_TABLE = "states";
public static final String DISTRICTS_TABLE = "districts";
private static final String KEY_ID = "_id";
private static final String KEY_NAME = "name";
private static final String STATES_TABLE_CREATE =
"CREATE TABLE " + STATES_TABLE + " ("
+ KEY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+ KEY_NAME + " TEXT);";
private static final String DISTRICTS_TABLE_CREATE =
"CREATE TABLE " + DISTRICTS_TABLE + " ("
+ KEY_ID + " INTEGER REFERENCES " + STATES_TABLE + "(" + KEY_ID + "), "
+ KEY_NAME + " TEXT);";
public DatabaseHelper( Context context )
{
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onConfigure( SQLiteDatabase db )
{
super.onConfigure(db);
db.setForeignKeyConstraintsEnabled(true);
}
@Override
public void onCreate( SQLiteDatabase db )
{
try
{
db.execSQL(STATES_TABLE_CREATE);
db.execSQL(DISTRICTS_TABLE_CREATE);
}
catch(SQLiteException e)
{
Log.e("Test2", "Error creating tables: " + e.getMessage());
}
}
@Override
public void onUpgrade( SQLiteDatabase db, int oldVersion, int newVersion )
{
Log.d("Test2", "Upgrading database from version " +
oldVersion + " to version " + newVersion);
try
{
db.execSQL("DROP TABLE IF EXISTS " + STATES_TABLE);
db.execSQL("DROP TABLE IF EXISTS " + DISTRICTS_TABLE);
}
catch(SQLiteException e)
{
Log.e("Test2", "Error dropping tables: " + e.getMessage());
}
onCreate(db);
}
}
Creating an instance of DatabaseHelper
will cause its onConfigure()
and onCreate()
methods to be called. After that, we have the green light to start adding states and districts to the database. This looks like:
DatabaseHelper dbHelper = new DatabaseHelper(this);
SQLiteDatabase db = dbHelper.getWritableDatabase();
...
lStateId = addState(db, "Andaman and Nicobar");
addDistrict(db, lStateId, "Nicobar");
addDistrict(db, lStateId, "North and Middle Andaman");
addDistrict(db, lStateId, "South Andaman");
lStateId = addState(db, "Chandigarh");
addDistrict(db, lStateId, "Chandigarh");
lStateId = addState(db, "Dadra and Nagar Haveli");
addDistrict(db, lStateId, "Dadra and Nagar Haveli");
lStateId = addState(db, "Daman and Diu");
addDistrict(db, lStateId, "Daman");
addDistrict(db, lStateId, "Diu");
lStateId = addState(db, "Lakshadweep");
addDistrict(db, lStateId, "Lakshadweep");
...
The addState()
method looks different than the one shown in the Hash section above. In short, it adds a row to the states
table, wrapped in a transaction. The identifier of the newly added row is returned to the caller (so that linked rows can be added to the districts
table).
private long addState( SQLiteDatabase db, String sName )
{
long lRow = -1;
try
{
db.beginTransaction();
ContentValues values = new ContentValues();
values.put(DatabaseHelper.KEY_NAME, sName);
lRow = db.insert(DatabaseHelper.STATES_TABLE, null, values);
db.setTransactionSuccessful();
}
catch(SQLiteException e)
{
Log.e("Test2", "Error adding state: " + e.getMessage());
}
finally
{
db.endTransaction();
}
return lRow;
}
The addDistrict()
method looks much the same, except that it is adding a row to the districts
table, like:
private void addDistrict( SQLiteDatabase db, long lStateId, String sName )
{
try
{
db.beginTransaction();
ContentValues values = new ContentValues();
values.put(DatabaseHelper.KEY_ID, lStateId);
values.put(DatabaseHelper.KEY_NAME, sName);
db.insert(DatabaseHelper.DISTRICTS_TABLE, null, values);
db.setTransactionSuccessful();
}
catch(SQLiteException e)
{
Log.e("Test2", "Error adding district: " + e.getMessage());
}
finally
{
db.endTransaction();
}
}
At this point, the astute observer would notice that each row insertion for either table is surrounded by a transaction. This is both a good thing and a bad thing. For the good, if an error occurs, only one row is affected; the other rows can be inserted independently. For the bad, the brief bit of empirical testing I did revealed that row insertion took a measurable amount of time. In response to this, I did end up putting the row insertion code in an AsyncTask
class to help free up the UI. You can find that in the attached source code.
Now that the states and districts have been added to the database, we need to issue a "select all
" query against the states
table where each state name returned in the result set will get added to the array associated with the state's adapter. This looks like:
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.query(DatabaseHelper.STATES_TABLE,
new String[]{ DatabaseHelper.KEY_ID, DatabaseHelper.KEY_NAME },
null,
null,
null,
null,
DatabaseHelper.KEY_NAME);
if (cursor.moveToFirst())
{
do
{
long id = cursor.getLong(cursor.getColumnIndex(DatabaseHelper.KEY_ID));
String sName = cursor.getString(cursor.getColumnIndex(DatabaseHelper.KEY_NAME));
arrStates.add(new StateInfo(id, sName));
} while (cursor.moveToNext());
cursor.close();
}
db.close();
As an alternative to the above query, the arrStates
array could have been modified in the addState()
method.
The StateInfo
objects that are contained in arrStates
are just a little container class that holds the name and identifier of each row in the states
table. It also contains a toString()
method that gets called by the state's adapter when it needs the name of the state to display.
private class StateInfo
{
private long m_lStateId;
private String m_sName;
public StateInfo( long lStateId, String sName )
{
m_lStateId = lStateId;
m_sName = sName;
}
@Override
public String toString()
{
return m_sName;
}
}
Lastly, we need to respond to a selection change in the state's spinner control. For the Hash and JSON sections above, the first thing we did was to use the position value passed to onItemSelected()
to look up the name of the selected state in the HashMap
and get its list of districts. Since state and district information is now coming from a database instead, the approach is a tad different. We'll be issuing a SQL query against the districts
table using the selected state's row id for the WHERE
clause. Then, for every district name returned in the result set, we add each one to the array tied to the district's adapter.
spinStates.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener()
{
@Override
public void onItemSelected( AdapterView<?> parent, View view, int position, long id )
{
SQLiteDatabase db = dbHelper.getReadableDatabase();
arrDistricts.clear();
StateInfo si = arrStates.get(position);
Cursor cursor = db.query(DatabaseHelper.DISTRICTS_TABLE,
new String[]{ DatabaseHelper.KEY_NAME },
DatabaseHelper.KEY_ID + " = ?",
new String[]{ String.valueOf(si.m_lStateId) },
null,
null,
DatabaseHelper.KEY_NAME);
if (cursor.moveToFirst())
{
do
{
String sName = cursor.getString(cursor.getColumnIndex(DatabaseHelper.KEY_NAME));
arrDistricts.add(sName);
} while (cursor.moveToNext());
cursor.close();
}
db.close();
if (arrDistricts.size() == 0)
arrDistricts.add("No districts found for the selected state");
adapterDistricts.clear();
adapterDistricts.addAll(arrDistricts);
}
});
For smaller applications such as this, where the amount of data being queried is small-ish (e.g., the largest number of districts for a given state is only 75
), this may be an acceptable approach. For larger data sets, or for those situations where selections happen on a frequent basis, this query may prove to be an unwelcome bottleneck. You would need to create metrics on all three approaches to see which one best suits your need.
Epilogue
This was a fun exercise to work on, and I hope it proves useful to future readers (who are struggling with spinner control selection changes). One thing that I noticed while working on the SQLite section was the time it took to insert all of the rows into the states and districts tables. When I tested this on an emulator, I simply expected the time to be somewhat slower than that of an actual device. That much ended up being true, but the thing that still puzzles me is that each successive time I ran the app on the emulator, it took a few more seconds to complete. I experimented with a few things to try and uncover a possible reason why (e.g., deleting the database before each run, deleting and reinstalling the app each time), but never came to anything conclusive. If you have a theory, leave a comment below.
Yes, the project's name is Junk. Because this was a weekend, throw-away project, I did not go through all the steps to make it a "pretty" app. You'll also need to change <activity android:name=".MainActivity2">
in the AndroidManifest.xml file to demonstrate each of the three methods.
Enjoy!