使用场景
对于联系人,城市列表等,按照字母排序的都常用到
需求分析
一般需要的基本功能为
- 所有数据按照关键字段,一般是name姓名,进行拼音字母排序
- 每个拼音的首字母第一次出现的位置,额外显示个字母栏header,如图片中顶部的M和下面的N
- 右侧有一个字母导航栏,从A~Z排序,有两种 1) 显示所有字母,如图1 2) 只显示列表中全部数据有的首字母,如图2
- 选中字母导航栏时(DOWN按下,和MOVE按下后移动)则会让列表跳转到该字母首次出现的位置,如图1的M 通常还会同时在屏幕居中位置显示一个提示框,提醒当前选择的字母 注意: 如果是上面3中的1)情况,则可能出现选择了某字母,但是列表中没有该数据,则只显示提示框,不作列表跳转
首字母数据来源
- 服务器返回 条件允许的情况下,尽量让服务器直接提供该字段,最好同时提供name对应的拼音和大写首字母,至少也要提供拼音
- 客户端解析 服务器不提供只能本地解析了,如利用pinyin4j等第三方工具将中文转为拼音字母 服务器处理的好处是,只要处理一次再保存到数据库中即可,而客户端需要每次获取数据的时候都转一遍比较慢
数据排序
- 服务器排序 服务器在获取数据时按照关键字段的拼音进行排序,返回给终端
- 本地排序
服务器不提供的情况下,或者本地进行数据修改后造成排序集合混乱的情况下,则需要客户端进行再次排序
排序方法如下
1 2 3 4 5 6
Collections.sort (datas , new Comparator<DataBean>() { @Override public int compare(DataBean lhs, DataBean rhs) { return lhs.getName().compareTo(rhs.getName()); } });
利用Collections.sort排序方法,第一个为需要排序的数据List集合,第二个为比较器,提供排序规则 在compare方法中处理规则,两个参数分别是需要对比的俩数据, 这里我们可以让他们的拼音字段进行compareTo比较,String类型的拼音会自动按照字母升序排列 注意: 如果服务器只返回中文,需要转为拼音后排序
列表首字母第一次出现位置的处理逻辑 前提是数据已经按照字母顺序排好了,如果是乱序那首字母第一次出现的位置就没有意义了 通常会用一个Map键值对集合保存数据 key为String类型,对应首字母 value为Integer型,对应字母首次出现的位置索引
处理方法 方式1. 循环数据集合,如果map中没有这个key首字母,就保存首字母和此时的index 之后再获取到数据首字母是集合中存在时,就代表不是第一次存在,就不作做put操作了
// 初始化<首字母, 首字母第一次出现位置>的键值对
1
2
3
4
5
6
7
8
9
letterPositionMap = new HashMap<String, Integer>();
for (int i = 0; i < datas .size(); i++) {
DataBean data = datas.get(i);
String firstletter = data.getFirstLetter();
// 不包含代表是第一次出现,记录位置
if (!letterPositionMap.containsKey(firstletter)) {
letterPositionMap.put(firstletter, i);
}
}
由于List中的数据最终也会按照顺序依次显示到列表上,所以这里for循环的 i 也对应在列表中的position位置
方式2. 倒序遍历数据集合,直接进行无脑put操作,由于put同一个key会让后一个数据覆盖前一个数据 所以倒序在put某个字母时最终会保存最后一次put的数据,即对应正序的第一次该字母出现的位置了
1
2
3
4
5
6
letterPositionMap.clear();
for (int i = datas .size() - 1; i >= 0; i--) {
DataBean data = datas.get(i);
String firstletter = data. getFirstLetter();
letterPositionMap.put(firstletter, i);
}
和方式1对比,代码行数更少,但是效率有待验证 一般方式1即可,这里是鼓励尽量寻找多种方式实现
列表Item的字母栏处理 在适配器的构造方法中做初始化处理,排序以及初始化首字母第一次出现位置的键值对数据集合
显示要求是,首字母第一次出现位置的Item额外显示一个字母栏Header 所以一般会在item的布局中顶部加一个字母栏,设置为gone 然后在getView显示item的时候判断,当前位置是否位首字母第一次显示的位置,如果是才显示字母栏,否则隐藏
1
2
3
4
5
6
7
8
9
10
11
DataBean item = getItem(position);
String firstletter = item.getFirstLetter();
// 判断当前item的位置,是否等于其首字母第一次出现的位置
if (letterPositionMap .get(firstletter) == position) {
// 是的话显示首字母栏,并设置字母
holder.tv_firstletter.setVisibility(View. VISIBLE);
holder.tv_firstletter.setText(firstletter);
} else {
// 不是的话隐藏首字母栏
holder.tv_firstletter.setVisibility(View. GONE);
}
字母导航栏自定义控件 - 字母排列展示 实现方式同样有多重,比如onDraw直接绘制A~Z的字母,比较麻烦就不介绍了 这里介绍的是一种比较简单的自定义控件实现方式,利用已有的视图组合一个自定义控件
逻辑是: 垂直排列的 LinearLayout 作为整个容器 根据数据几何,在其中add TextView作为child 比如A~Z就add26个,每个对应一个字母
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
private void initItems() {
// 先删除所有child
removeAllViews();
// 根据item添加TextView
for (CharSequence s : mItems) {
TextView t = new TextView(getContext());
t.setText(s);
// TODO setTextSize
t.setTextSize(10);
// TODO setTextColor
t.setTextColor(Color. WHITE);
LinearLayout.LayoutParams params;
if( layoutType == 1) {
// 1为居中分布,由于gravity本来就是CENTER_VERTICAL,所以所有的child都会垂直居中
params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams. WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT );
// TODO 字母之间的间距
int padding = dp2px(4);
params.setMargins(0, padding, 0, 0);
} else {
// 0和其他数字都取默认的平均分布,利用weight平均分布
params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams. WRAP_CONTENT, 0);
params. weight = 1;
}
t.setLayoutParams(params);
addView(t);
}
}
这里我们做一个for循环,对数据items进行循环,比如items里面是A~Z共26个字符串,则循环创建添加26个TextView 要注意的是我们需求分析时有两种情况,显示全部和显示部分
所以这里布局我提供了一个多种类型判断layoutType,居中或者平均分布 构造方法中先布局设为垂直居中 setGravity(Gravity.CENTER_VERTICAL);
-
居中分布 直接设置child的大小,由于本身就是垂直居中,所以多个child会集中在线性布局的中间部分,挤在一起 为了防止太密集这里还添加了margin,当然也可以用padding
-
平均分布 利用height=0; weight=1;让所有的child按照同比例分布,占满整个parent高度 这个时候,由于是占满高度,所以CENTER_VERTICAL有没有无所谓了
字母导航栏自定义控件 - Touch事件处理 判断当前touch的动作和位置,然后利用接口提供出去
直接复写onTouchEvent处理
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 ev) {
final int action = ev.getAction();
TextView child = null;
switch (action & MotionEvent. ACTION_MASK) {
case MotionEvent. ACTION_DOWN:
case MotionEvent. ACTION_MOVE:
// 按下和移动时都视为选中操作,根据点击位置获取TextView类型的child,然后获取其Text
child = findChildByLocation(ev.getX(), ev.getY());
if ( listener != null) {
listener.onLetterSelected(child == null ? null // 如果该位置没有child,则返回null
: child.getText().toString());
}
break;
case MotionEvent. ACTION_UP:
// 抬起视为非选中操作
if ( listener != null) {
listener.onLetterSelected( null);
}
break;
}
return true;
}
关键代码在于findChildByLocation即根据点击位置获取对应的TextView
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private TextView findChildByLocation(float x, float y) {
TextView child = null;
// 第一个child的top位置
int mContentTop = getChildAt(0).getTop();
// 最后一个child的bottom位置
int mContentBottom = getChildAt(getChildCount() - 1).getBottom();
// bottom-top算出所有child的范围,然后除以child数量,算出每一个child的高度
int defSize = (mContentBottom - mContentTop) / mItems. length;
// 用选择的位置y坐标减去整个items的顶部位置,除以defSize算出选择的位置y对应的child索引
int index = (int) ((y - mContentTop) / defSize);
// 如果索引在items的范围以内,且x轴为超出整个字母栏宽度范围,则获取对应的child
if (index >= 0 && index < mItems. length && x >= 0 && x <= getWidth()) {
child = (TextView) getChildAt(index);
}
return child;
}
这里要注意,不能直接利用整个容器高度去除数据数量算每一个item的高度, 因为我们还提供一个居中显示的效果,且还要考虑到容器paddingTop和Bottom的情况
所以利用最后一个child的bottom减去第一个child的top计算所有child占据的总高度 这里关键在于 int index = ( int) ((y - mContentTop) / defSize); 我们child的起始位置的第一个child的top,不是整个容器的最顶部,所以要注意 y - mContent, 再除以每个item的高度,再转为int舍去小数就获取到了当前位置对应的child索引了
举个例子 10个item, 第一个item的top为y=10;最有一个为y=110,则每个item的高度就是(110-10)/10=10 如果点击了y=13的位置,即第一个item的上半部分,则索引就是 (13-10)/10=0.3转为int就是0,即第一个item 如果点击了y=18的位置,即第一个item的下半部分,则索引就是 (18-10)/10=0.8转为int就是0,即第一个item 可以看得出,这里的小数转int时千万不能四舍五入了,直接转int去小数正好合适
但是y=5或者y=115,则index分别对应 -1和10(第十一个),即超出范围了,所以最后还要进行各判断处理 此外x轴横向上也不能超过容器宽度范围
回调接口如下,很简单的一个方法,在onTouch时调用,这里只提供了一个方法同时包含了选中和非选中两个状态 如果不满足也许需要可以写两个方法,touch的up时调用一个,down和move时调用一个,以作区分 具体使用在后面会介绍
1
2
3
4
5
6
7
8
9
10
11
12
13
private OnLetterChangedListener listener ;
public void setOnLetterChangedListener(OnLetterChangedListener listener) {
this.listener = listener;
}
public interface OnLetterChangedListener {
/**
* 选择字母
* @param letter 选择的字母; null时代表无选中的字母,或抬起未选择状态
*/
void onLetterSelected(String letter);
}
整合使用
主要是自定义控件和列表的联动 选中字母时,让列表跟着定位,同时再展示选中字母提示框 关键代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
lb.setOnLetterChangedListener( new OnLetterChangedListener() {
@Override
public void onLetterSelected(String letter) {
if (letter == null) {
// 如果是未选择(抬起)状态,不显示overlay提示字母
tv_firstletter_overlay.setVisibility(View. GONE);
} else {
// 如果是选择(按下和滑动)状态,显示overlay提示字母
tv_firstletter_overlay.setVisibility(View. VISIBLE);
tv_firstletter_overlay.setText(letter);
// 获取首字母在列表中首次出现的位置
int position = adapter.getLetterPosition(letter);
// 可以获取到位置时,让列表跳转到该位置
if (position != -1) {
lv.setSelection(position);
}
}
}
});
此外如果是希望用之前需求中的图2方式展示,则可以在数据获取setAdapter后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
adapter = new ListLetterAdapter( this, datas);
lv.setAdapter( adapter);
// TODO 只显示列表数据中包含的首字母
// 获取数据的所有首字母Set集合
Set<String> firstletterSet = adapter.getLetterPositionMap().keySet();
// 将Set转为数组
String[] items = firstletterSet.toArray (new String[firstletterSet.size()]);
// 按照字母排序
Arrays.sort (items);
// 设置新的items
lb.setItems(items);
// 由于显示部分首字母,所以用居中布局更好,平均布局在数据较少时会显得空
lb.setItemsLayoutType(1);
当然,已有数据的首字母列表也可以用其他方式获取 我这里是先在adapter中初始化后,再把生成的首字母第一次位置键值对的健集合取出来转换成的字符串数据
done