android自定义控件之索引控件的实现

IndexView

IndexView用于为ListView添加索引。
首先看一下两个demo的效果,分别是音乐列表和联系人列表:

如何实现

构造函数

1
2
3
public IndexView(Context context, AttributeSet attrs, int defStyle);
public IndexView(Context context, AttributeSet attrs);
public IndexView(Context context);

构造函数中做了如下几件事。

1 读取自定义属性

自定义属性包括如下这些。

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="IndexView">
<attr name="heightOccupy" format="float" />
<attr name="indexTextColor" format="color" />
<attr name="selectIndexTextColor" format="color" />
<attr name="selectIndexBgColor" format="color" />
<attr name="indexTextSizeScale" format="float" />
<attr name="tipTextColor" format="color" />
<attr name="tipBg" format="color|reference" />
</declare-styleable>
</resources>

描述如下:

属性名 描述
heightOccupy 0.0f-1.0f, 索引占据的高度比例,小于1的部分将分别在上下两端留白
indexTextColor 未选中的索引字体颜色
selectIndexTextColor 选中的索引字体颜色
selectIndexBgColor 选中的索引背景色
indexTextSizeScale 0.0f-1.0f, 控制索引字体相对大小, 默认0.65f
tipTextColor 提示框字体颜色
tipBg 提示框背景

2 初始化提示框

提示框就是用来提示刚刚点击或者滑到的索引的符号。并且提示框可以在显示一段时间后自动隐藏。

3 初始化用于绘制的画笔

onMeasure方法

该方法确定控件所要占据的宽和高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
width = MeasureSpec.getSize(widthMeasureSpec);
height = MeasureSpec.getSize(heightMeasureSpec);
if(widthMode != MeasureSpec.EXACTLY){
width = Math.min(DEFAULT_WIDTH, width);
}
singleIndexHeight = height * heightOccupy / INDEXES.length();
indexTextSize = Math.min(singleIndexHeight, width) * indexTextSizeScale;
indexTextPaint.setTextSize(indexTextSize);
selectIndexTextPaint.setTextSize(indexTextSize);
setMeasuredDimension(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
}

onDraw方法

该方法实现对控件的绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
protected void onDraw(Canvas canvas) {

super.onDraw(canvas);
float singleIndexWidth = getMeasuredWidth();
for (int i = 0; i < INDEXES.length(); i++) {

float x = (singleIndexWidth - indexTextPaint.measureText(INDEXES, i, i + 1)) / 2;
float baseline = (1 - heightOccupy) / 2.0f * height + i * singleIndexHeight + Util.getBaseline(0, singleIndexHeight, indexTextPaint);

if(i == selectIndex){
float left = 0.1f * singleIndexWidth;
float top = (1 - heightOccupy) / 2.0f * height + i * singleIndexHeight + 0.05f * singleIndexHeight;
float right = 0.9f * singleIndexWidth;
float bottom = (1 - heightOccupy) / 2.0f * height + i * singleIndexHeight + 0.95f * singleIndexHeight;
canvas.drawRoundRect(new RectF(left, top, right, bottom), 5, 5, selectIndexBgPaint);
canvas.drawText(String.valueOf(INDEXES.charAt(i)), x, baseline, selectIndexTextPaint);
}else{
canvas.drawText(String.valueOf(INDEXES.charAt(i)), x, baseline, indexTextPaint);
}
}
}

触摸事件

这里有一个注意点,当触摸到一个新的索引时,必然需要显示一个提示框,但不一定会切换到这个新的索引,因为这个新索引不一定存在数据。

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
@Override
public boolean onTouchEvent(MotionEvent event) {

float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (tipVisible) {
this.removeCallbacks(hideTipRunnable);
}
break;
case MotionEvent.ACTION_UP:
this.postDelayed(hideTipRunnable, TIP_SHOW_TIME);
break;
}
int newSelectIndex = getSelectByY(y);
if (selectIndex != newSelectIndex && onIndexChangeListener != null) {
onIndexChangeListener.OnIndexChange(newSelectIndex, INDEXES.charAt(newSelectIndex));
tip.setText(String.valueOf(INDEXES.charAt(newSelectIndex)));
if (!tipVisible) {
tipVisible = true;
tip.setVisibility(VISIBLE);
}
}
return true;
}

自定义监听器

IndexView提供了1个接口OnIndexChangeListener,由触摸事件检测到索引变化时触发。本接口不需要使用者自己实现该接口。

1
2
3
protected interface OnIndexChangeListener {
void OnIndexChange(int select, char index);
}

如何绑定IndexView和ListView

IndexView和ListView显然是耦合的。
一方面,当触摸索引时,IndexView需要访问到ListView确认这个新的索引是否存在数据,以此决定是否切换索引,如果存在数据,还要通知ListView切换到相应的位置去;
另一方面,当滑动ListView时,随着ListView的位置变化,需要通知IndexView将索引切换到对应的位置去。
上面所述,通过抽象类Binder类来实现。在bind()方法中通过给IndexView设置一个OnIndexChangeListener监听器,给ListView设置一个OnScrollListener监听器就实现了二者的绑定。
使用本控件的人只需要实例化一个继承Binder的匿名内部类的对象即可,并且只需要实现public abstract String getListItemKey(int position);这个abstract函数即可。
getListItemKey()这个方法的意思是为每一个列表项指定一个用来索引的字符串。比如在demo1中用来索引的是歌曲名称,在demo2中用来索引的是用户名,如果在demo1中想改为用歌手名来索引,可以很方便的进行修改。
这里有一个注意点,当由于触摸IndexView导致ListView滑动时,也会触发ListView的OnScrollListener,这时需要避免掉IndexView再次发生索引切换,使用了一个标记flag来解决。

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
public abstract class Binder {

private ListView listView;
private IndexView indexView;
private boolean flag = false;

public Binder(ListView listView, IndexView indexView){
this.listView = listView;
this.indexView = indexView;
}

public void bind(){
indexView.setOnIndexChangeListener(new IndexView.OnIndexChangeListener() {
@Override
public void OnIndexChange(int selectIndex, char index) {
ListAdapter adapter = listView.getAdapter();
int pos = -1;
for (int i = 0; i < adapter.getCount(); i++) {
char currentIndex = Util.getIndex(getListItemKey(i));
if (currentIndex == index) {
pos = i;
break;
}
}
if (pos != -1) {
listView.setSelection(pos);
flag = true;
indexView.setSelectIndex(selectIndex);
}
}
});

listView.setOnScrollListener(new AbsListView.OnScrollListener() {

@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if(flag){
flag = false;
return;
}
indexView.setIndex(Util.getIndex(getListItemKey(firstVisibleItem)));
}

@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
}
});
}
public abstract String getListItemKey(int position);
}

拼音排序器

在获取了ListView的数据之后,需要先对数据进行排序。
使用抽象类PinyinComparator可以很方便的实现。该类已经实现了两个字符串的比较函数,使用时只要对实体类(java bean)进行排序即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public abstract class PinyinComparator<T> implements Comparator<T> {
public abstract int compare(T s1, T s2);

public int compare(String s1, String s2) {
char i1 = Util.getIndex(s1);
char i2 = Util.getIndex(s2);
if(i1 == '#' && i2 == '#'){
return Util.getStringForSort(s1).compareTo(Util.getStringForSort(s2));
}else if(i1 == '#'){
return 1;
}else if(i2 == '#'){
return -1;
}else{
return Util.getStringForSort(s1).compareTo(Util.getStringForSort(s2));
}
}
}

demo1中的使用的例子如下,仅需要使用匿名内部类,实现抽象方法即可。

1
2
3
4
5
6
Collections.sort(items, new PinyinComparator<Item>() {
@Override
public int compare(Item s1, Item s2) {
return compare(s1.getSong(), s2.getSong());
}
});

汉字转拼音使用了开源项目jpinyin。

github

github项目主页: IndexView