Android 自定义组合控件:SherlockSpinner

如果你觉得从头开始自定义一个 View 比较麻烦,那么组合几个系统现有控件来实现需求的功能,会是你很好的一个选择。

一、前言

最近在项目中,需要使用 Spinner 来实现下拉选择功能,UI 方面倒是要求不多。但是难点在于一个界面中有多个 Spinner,并且有联动关系,数据需要在点击 Spinner 的时候请求服务器。而且当前 Spinner 数据依赖于前面选择的一个或多个结果,当获取到最新数据后,才显示下拉选项。

比如说我需要先选择一个仓库,再选择项目(依赖前面选择的仓库),再选择一个批次(依赖前面选择的仓库和项目),如果我在选择完仓库时,就去判断来预加载项目和批次的数据,会使依赖逻辑变得非常复杂。

这时候就想在每次点击一个 Spinner 的时候,去判断依赖的选项是否已经选择,未选择就提示需先选择;如果已选择,则进行网络请求,加载数据显示到下拉选项中。

(本例使用选择语言来进行演示。)

二、使用系统 Spinner

1. 首先,我想到的便是使用系统的 Spinner,说干就干,XML 先上:

1
2
3
4
<Spinner
android:id="@+id/spn_languages"
android:layout_width="match_parent"
android:layout_height="36dp"/>

2. 然后代码设置 Adapter 匹配数据、设置 OnItemSelectedListener 绑定 item 选择的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spnLanguages = (Spinner) findViewById(R.id.spn_languages);
ArrayAdapter<String> mAdapterSystemSpinner = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, mLanguages);
spnLanguages.setAdapter(mAdapterSystemSpinner);
spnLanguages.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
showMessage("Select " + mLanguages[position]);
}

@Override
public void onNothingSelected(AdapterView<?> parent) {
showMessage("Select Nothing.");
}
});

OK,现在 Spinner 可以使用了:

3. 接着我们来设置点击事件

1
2
3
4
5
6
spnLanguages.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showMessage("Click Spinner");
}
});

什么情况,运行后直接崩了?

1
2
3
4
5
6
7
8
9
FATAL EXCEPTION: main
Process: com.sherlockshi.widget.sherlockspinner, PID: 11757
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.sherlockshi.widget.sherlockspinner/com.sherlockshi.widget.MainActivity}: java.lang.RuntimeException: Don't call setOnClickListener for an AdapterView. You probably want setOnItemClickListener instead
...
Caused by: java.lang.RuntimeException: Don't call setOnClickListener for an AdapterView. You probably want setOnItemClickListener instead
at android.widget.AdapterView.setOnClickListener(AdapterView.java:798)
at com.sherlockshi.widget.MainActivity.initSystemSpinner(MainActivity.java:169)
at com.sherlockshi.widget.MainActivity.onCreate(MainActivity.java:48)
...

简单来说就是,AdapterView 不能设置 Click 事件,看下 Spinner 源码,确实是继承自 AdapterView。(至于为什么 AdapterView 不能设置 Click 事件,这个暂未深究。)

4. 那我们就设置 Touch 事件喽:

1
2
3
4
5
6
7
8
9
10
11
spnLanguages.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
showMessage("Touch Spinner");
break;
}
return false;
}
});

这下确实是可以响应点击事件了,但是响应完,下拉选择就直接显示出来了,无法满足我们的需求。

而且 Spinner 还有个问题,一进入界面的时候,默认就会选择第一项,而我们并不需要这样的默认值。

三、CustomSpinner = EditText + ListPopupWindow

那我们是否可以使用别的控件,来实现相同的功能呢?答案是肯定的,我们用 EditText 来接收点击事件,而在请求完数据之后,使用 ListPopupWindow 来显示下拉选项,选择 EditText 的原因主要有以下几点:

  • 默认在底部会有带颜色的横线,Material Design 风格的 EditText 看起来效果很不赖
  • EditText 可以方便的配置上、下、左、右四个位置的小图标,我们可以在右侧放置一个向下的三角箭头,使它看起来像一个系统的 Spinner

而选择 ListPopupWindow 则是因为:

  • 可以方便的使用下拉列表
  • 可以自由设置锚点

1. XML 布局

布局文件依旧很简单,只要一个简单的 EditText,配上一个右侧的下拉图标:

1
2
3
4
5
6
7
<EditText
android:id="@+id/et_languages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="right"
android:drawableRight="@drawable/ic_dropdown"
android:hint="Please Select..."/>

2. 代码中创建 ListPopupWindow

此部分包含以下逻辑:

  • 初始化 ListPopupWindow,并关联到 EditText 上
  • 当点击 EditText 时,请求数据,请求完成后,显示 ListPopupWindow
  • 选中 ListPopupWindow 的某一项后,将此项内容更新到 EditText 中,并隐藏 ListPopupWindow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
etLanguages = (EditText) findViewById(R.id.et_languages);
etLanguages.setKeyListener(null); // 设置 EditText 不可编辑,等同于在 xml 中设置 editable="false"
lpwLanguages = new ListPopupWindow(this);
mAdapterLanguages = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, mLanguages);
lpwLanguages.setAdapter(mAdapterLanguages);
lpwLanguages.setAnchorView(etLanguages); //设置 ListPopupWindow 的锚点,即关联 PopupWindow 的显示位置
lpwLanguages.setModal(true); // 是否为模态,当设置为 true 时,会处理返回按键的事件

lpwLanguages.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
showMessage("Select " + mLanguages[position]);

etLanguages.setText(mLanguages[position]);
lpwLanguages.dismiss();
}
});

// 如果使用 onClick 事件,会出现第一次点击只获取焦点,第二次点击才出现下拉
etLanguages.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
// Do what you want
getDataFromNet();
}
return false;
}
});

public void getDataFromNet() {
// 延时 2 秒后,修改源数据,用来模拟网络请求
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}

mLanguages[1] = "C+++++++++++++";
mAdapterLanguages.notifyDataSetChanged();

runOnUiThread(new Runnable() {
@Override
public void run() {
lpwLanguages.show();
}
});
}
}).start();
}

实现效果如下所示:

3. 小结

EditText 配合 ListPopupWindow 组合实现 Spinner 的功能,使用起来倒是简单,逻辑也挺清晰的,但是如果界面上有三四个 Spinner,那不是就得把类似的代码写上三四遍?

其实我们并不关心内部是用 ListPopupWindow 或者其它的控件来实现,也不想处理任何关于 ListPopupWindow 的细节。我们关心的只有 Spinner 的初始化、适配数据、Item 选择事件(ItemClick 事件),如果可以,就再加上 Spinner 的点击事件(Click 或 Touch 事件)、自由控制 Spinner 的显示时机。

那有没有简单易用的方法,可以直接像使用系统的 Spinner 一样,来使用 EditText 和 ListPopupWindow 的组合呢?并且可以提供 Spinner 的点击事件?答案是肯定的,详情且看下一节。

四、自定义组合控件

在第二部分我们可以看出,我们的控件要满足以下两大功能:

  1. 像系统 Spinner 一样简单易用:初始化、适配数据、Item 选择事件(ItemClick 事件)
  2. 支持点击事件(Click 或 Touch 事件),自由控制下拉框显示时机

由于控件源码稍长,就不贴出来了,有兴趣可以点击文末的 Github 链接,源码也比较简单,只是进行控件的组合,并提供相应的方法进行调用,下面主要介绍下使用方法。

1. 引入依赖

1
2
3
4
dependencies {
...
compile 'com.sherlockshi.widget:sherlockspinner:1.0.2'
}

2. 使用方法

2.1 像使用系统 Spinner 一样,在 XML 文件中使用:

1
2
3
4
5
6
<com.sherlockshi.widget.SherlockSpinner
android:id="@+id/sherlock_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:lineColor="#00FF00"
android:hint="Please Select..."/>

SherlockSpinner 有以下属性:
lineColor: 设置底部横线的颜色
同时支持使用代码进行配置:mSherlockSpinner.setLineColor(0x00FF00);

由于 SherlockSpinner 继承自 EditText, 所以你可以使用 EditText 的其它属性,例如 gravitytextSizetextColor

2.2 还是像使用系统 Spinner 一样,在代码中设置 AdapterItemClickListener:

1
2
3
4
5
6
7
8
9
mSherlockSpinner = (SherlockSpinner) findViewById(R.id.sherlock_spinner);
ArrayAdapter<String> mAdapterLanguages = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, mLanguages);
mSherlockSpinner.setAdapter(mAdapterLanguages);
mSherlockSpinner.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
showMessage("Select " + mLanguages[position]);
}
});

以上,就可以像系统 Spinner 一样使用 SherlockSpinner 了,如果没有别的需求,这样也就够用了。如果你有点击请求网络数据,再异步显示下拉框的需求,可以看第 3 步的使用方法。

2.3 (可选) 如果你想在异步加载数据后,再显示出更新后的数据,你可以使用它的点击事件来处理

记住:在获取数据后,你必须手动调用 sherlockSpinner.show() 方法来显示 SherlockSpinner 的下拉选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
mSherlockSpinner.setOnClickListener(new SherlockSpinner.OnClickListener() {
@Override
public void onClick(View v) {
getDataFromNet();
}
});

public void getDataFromNet() {
// after delay 2s, modify the source data, to simulate net request
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}

mLanguages[4] = "Javaaaaaaaaaaa";
mAdapterLanguages.notifyDataSetChanged();

// then you must manually call sherlockSpinner.show()
runOnUiThread(new Runnable() {
@Override
public void run() {
mSherlockSpinner.show();
}
});
}
}).start();
}

3. 更多属性

  • 由于 SherlockSpinner 继承自 EditText,所以你可以使用 EditText 的其它属性,例如 gravitytextSizetextColor
  • SherlockSpinner 还有一个属性,可以设置下拉框的显示位置,即锚点设置:
1
mSherlockSpinner.setAnchorView(findViewById(R.id.llyt_anchor));

效果如下图中 4 和 5 的区别,在第 4 部分中,下拉框停靠在 Spinner上;而第 5 部分中,下拉框停靠在 Spinner 所在的整行布局上,宽度更大。

五、其它

另外,一个小小的 Tip:
在 styles.xml 文件中配置以下代码,可使下拉框带上漂亮的分割线:

1
2
3
4
5
6
7
8
9
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
...
<item name="android:dropDownListViewStyle">@style/mySpinnerStyle</item>
</style>


<style name="mySpinnerStyle" parent="android:Widget.ListView.DropDown">
<item name="android:divider">#E0E0E0</item>
<item name="android:dividerHeight">0.1dp</item>
</style>

项目代码已共享到 Github:SherlockSpinner
欢迎 fork、star、issue。

PS:欢迎关注 SherlockShi 个人博客

感谢你的支持,让我继续努力分享有用的技术和知识点!