Spinner 控件





5.00/5 (1投票)
一个 Spinner 控件中的选择如何决定另一个 Spinner 的内容。
引言
在带有“Android”标签的“快速问答”版块中,我经常看到一个类似的问题:“如何让一个 spinner 的值(或列表)根据另一个 spinner 的选择而改变?”嗯……
很多年前,我做了一个“销售税”应用程序,用于处理我所在州的在线税表。它把所有的县放在一个 spinner 控件中,把所有的城镇放在另一个 spinner 控件中。有 77 个县和数百个城镇,这组合起来数量庞大。除了记住给定县的所有城镇,或者包含给定城镇的所有县,我让每个 spinner 控件的内容响应另一个的选择。一旦两者都有选择,并且输入了价格,就会显示总价和所有销售税(州、县、镇)。它运行得相当不错,而且容纳这一切的数据结构也并不复杂。
鉴于此,我认为一篇比我上面描述的简单得多的文章会是一个受欢迎的补充。在上述问题中,每一个似乎都从不同的地方获取数据。一个会把数据存储在项目的 strings.xml 文件中,另一个会把数据存储在 SQLite 数据库中。在本文中,我将展示三种不同的数据存储方式,如何从中提取数据,以及如何响应 spinner 控件的选择。
几乎任何东西都可以用来填充这两个 spinner 控件:汽车品牌和型号、体育队和球员、国家和语言、州和邮政编码或区号等。在这个练习中,我选择了印度的州和地区。没有其他原因,就是因为数据量很大,而且提问者似乎都来自那里,我从 这里 获得了数据。让我们开始吧!
哈希
HashMap
实现 Map
接口,并将数据存储为键值对。这使得州作为键,其地区列表作为值的存储媒介非常完美。这给我们带来了类似这样的东西:
HashMap<String, ArrayList<String>> m_hashStates = new HashMap<String, ArrayList<String>>(36);
我选择预先分配是因为我提前知道有多少个州。对于如此小的规模,这可能不会产生任何显著差异。
填充这个 HashMap
非常简单,而且由于这会多次完成,我把它封装在一个函数里,就像这样:
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;
}
请注意,添加到映射中并且返回给调用者的空数组。因此,要添加一个州及其地区到 HashMap
,您只需调用该函数,就像这样:
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");
我选择不将这些 string
存储在 strings.xml 文件中,因为它们既不需要翻译也不需要格式化。
在所有数据加载到 HashMap
后,我们需要将它的键(州名)添加到属于 spinner 控件的适配器中。幸运的是,ArrayAdapter
、ArrayList
和 HashMap
都提供了可以很好地结合在一起以完成此操作的构造函数和方法:
m_adapterStates.addAll(new ArrayList(m_hashStates.keySet()));
通常,在向 ArrayAdapter
对象添加项目时,我们会调用其 notifyDataSetChanged()
方法,以便附加的列表刷新自身。在这种情况下,这是不必要的,因为 addAll()
已经为我们完成了。
我们需要做的最后一件事是响应选择更改。这通过调用 spinner 的 setOnItemSelectedListener()
方法并提供一个 onItemSelected()
回调来完成。适配器中选定项目的索引存储在 position
变量中。有了这个值,我们就可以获得选定州(即 HashMap
中的键)的名称。然后,我们可以获得相关地区的列表并更新其 spinner 控件。这看起来像这样:
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);
}
}
当加载一个 spinner 控件并让它的选择决定另一个 spinner 控件中显示的内容时,这确实就是这么简单。我见过很多别人尝试实现这个的例子,但代码却复杂得多(曲折)。不要让事情比它需要的更难! 让我们来看一个稍微不同的例子。
JSON
如今,提到任何带有 Java 这个词的东西,尤其是 JavaScript,似乎都会引起争论(就像说你曾经或仍然使用 [Visual]Basic 编程,或者在麦当劳吃饭一样)。每个人都有自己的观点,为什么它应该或不应该被使用,而且在大多数情况下,它们都是正确的。对我来说,如果一个工具能做它该做的事情并满足项目的所有要求,那么它就是正确的工具。总之,使用你熟悉和舒适的存储机制(例如 JSON、XML)。
在上一节中,州和地区的实现是代码本身中的字符串字面量。在本节中,我们将把数据移到一个单独的 JSON 文件中进行处理,然后像以前一样存储在 HashMap
中。这是该文件内容的一小部分:
{
"Mizoram": [
"Aizawl",
"Champhai",
"Kolasib",
"Lawngtlai",
"Lunglei",
"Mamit",
"Saiha",
"Serchhip"
],
"Nagaland": [
"Dimapur",
"Kiphire",
"Kohima",
"Longleng",
"Mokokchung",
"Mon",
"Peren",
"Phek",
"Tuensang",
"Wokha",
"Zunheboto"
],
...
}
在我们的例子中,有 36 对这样的键值对,键是州名,值是该州地区列表的数组。这个文件将存储在项目的 assets 文件夹中,所以打开该文件并将其内容读取到 JSONObject
对象中(读起来总感觉有点不对劲)如下所示:
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());
此时,obj
包含了我们开始提取键及其值所需的一切。与我们在 Hash 部分之前所做的类似,我们需要遍历每个键(即州),并为每个找到的键,将其值列表(即地区)添加到 ArrayList
容器中。JSONObject
类有一个方便的方法 keys()
,它会给出我们要遍历的键。然后,我们可以使用 getJSONArray()
方法来获取属于每个键的值。执行此操作的代码如下:
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);
}
响应州 spinner 控件的选择更改,onItemSelected()
方法与上面显示的相同,因此为简洁起见,我不在此重复。
SQLite
要讨论的最后一种方法涉及将数据存储在 SQLite 数据库中。这种方法与前几种方法既有相似之处,也有不同之处。添加到数据库的州和地区最初是字符串字面量,而不是用 HashMap
来存储州名和每个地区的地区名称列表,我们将把这些信息存储在两个 SQLite 表中。ArrayList
将再次用于存储州名,并与州的适配器关联。一个简单的数据库查询将检索地区名称。
数据库有两个表,每个表都有一个 _ID
和一个 NAME
字段。states
表中的 _ID
字段将是主键,并且是自增的。districts
表中的 _ID
字段将是带有约束启用的外键。
为了帮助管理数据库,需要一个 SQLiteOpenHelper
的扩展。该类的完整内容如下:
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);
}
}
创建 DatabaseHelper
的实例将调用其 onConfigure()
和 onCreate()
方法。之后,我们就可以开始向数据库添加州和地区了。这看起来像这样:
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");
...
addState()
方法与上面 Hash 部分显示的方法不同。简而言之,它在一个事务中向 states
表添加一行。新添加行的标识符会返回给调用者(以便可以将链接的行添加到 districts
表中)。
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;
}
addDistrict()
方法看起来差不多,除了它向 districts
表添加一行,如下所示:
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();
}
}
此时,敏锐的观察者会注意到,每个表的行插入都包含在一个事务中。这是一个好坏参半的情况。好处是,如果发生错误,只有一行会受到影响;其他行可以独立插入。坏处是,我进行的一些初步测试表明,行插入需要花费可衡量的时。作为回应,我最终将行插入代码放入了一个 AsyncTask
类中,以帮助释放 UI。您可以在附加的源代码中找到它。
现在州和地区已添加到数据库中,我们需要对 states
表执行“select all
”查询,其中结果集返回的每个州名都将添加到与州的适配器关联的数组中。这看起来像这样:
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();
作为对上述查询的替代,可以在 addState()
方法中修改 arrStates
数组。
arrStates
中包含的 StateInfo
对象只是一个小的容器类,用于保存 states
表中每一行的名称和标识符。它还包含一个 toString()
方法,当州的适配器需要州名显示时,该方法就会被调用。
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;
}
}
最后,我们需要响应州 spinner 控件中的选择更改。对于上面的 Hash 和 JSON 部分,我们做的第一件事是使用传递给 onItemSelected()
的 position 值在 HashMap
中查找选定的州名并获取其地区列表。由于州和地区信息现在来自数据库,因此方法略有不同。我们将使用选定州的行 ID 作为 WHERE
子句,对 districts
表执行 SQL 查询。然后,对于结果集中返回的每个地区名称,我们将每个名称添加到与地区适配器关联的数组中。
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);
}
});
对于像这样的小型应用程序,其中查询的数据量相对较小(例如,给定州的最大地区数量仅为 75
),这可能是一种可接受的方法。对于更大的数据集,或者在频繁发生选择更改的情况下,此查询可能会成为一个不受欢迎的瓶颈。您需要对所有三种方法进行度量,以确定哪种方法最适合您的需求。
结语
这是一次有趣的练习,我希望它能对未来(正在为 spinner 控件选择更改而挣扎)的读者有所帮助。我在处理 SQLite 部分时注意到的一件事是,将所有行插入州和地区表所需的时间。当我尝试在模拟器上测试它时,我只是期望时间比实际设备要慢一些。事实确实如此,但令我感到困惑的是,每次我在模拟器上运行应用程序时,完成时间都会增加几秒钟。我尝试了一些方法来找出可能的原因(例如,在每次运行前删除数据库,每次都删除并重新安装应用程序),但都没有得出明确的结论。如果您有理论,请在下方留言。
是的,该项目的名称是 Junk。因为这是一个周末的、一次性的项目,所以我没有采取所有步骤使其成为一个“漂亮”的应用程序。您还需要更改 AndroidManifest.xml 文件中的 <activity android:name=".MainActivity2">
来演示这三种方法。
尽情享用!