Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / Android

Spinner Controls

5.00/5 (1 vote)
13 Sep 2018CPOL9 min read 8.4K   103  
How the selection in one spinner control can govern the contents of another

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:

Java
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:

Java
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:

Java
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 strings 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:

Java
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:

Java
spinStates.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener()
{
    @Override
    public void onItemSelected( AdapterView<?> parent, View view, int position, long id )
    {
        // get the state that was selected and its list of districts
        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);

        // update the adapter with the new districts
        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:

Java
{
    "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:

Java
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:

Java
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));

    // all of the districts for this state have been added, so sort them
    Collections.sort(arrDistricts);

    // now with a state name and an array of districts, we can update the HashMap
    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:

Java
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";

    // columns
    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:

Java
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).

Java
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:

Java
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:

Java
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.

Java
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.

Java
spinStates.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener()
{
    @Override
    public void onItemSelected( AdapterView<?> parent, View view, int position, long id )
    {
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        arrDistricts.clear();

        // find only those districts belonging to the selected state
        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!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)