打造一个城市选择页面

又是很久没有写文章了,不写文章的这段日子里,感觉生活毫无乐趣,没有什么成就感,以后还是要多写啊,至少一周一篇吧。

需求

城市选择页面是很多 App 都有的组件,比如美团、大众点评之类的,而这个文章就是模仿美团的城市选择组件打造的,不过比起美团还是有差距的。
主要的需求有以下几点:

  1. 显示当前城市;
  2. 显示设备定位城市;
  3. 按照城市拼音进行排序和分类
  4. 城市首字母快速导航
  5. 城市搜索,关键字高亮 由于显示定位城市需要使用到第三方地图 SDK,为了专注的实现界面效果,这里就不具体实现了,模拟一下即可。

设计

根据需求来看,城市选择页面可以分为这么几个部分:

  • 搜索栏
  • 城市列表
  • 首字母索引导航
  • 搜索结果列表

为了更好的利用屏幕空间,把搜索栏与城市列表放在一起,也就是在同一个 RecyclerView 中。

差不多就是下面的样子:

  • 由于列表包含不同的布局,需要定义多个 ViewType
    • Type_Search 搜索栏
    • Type_Current 当前城市
    • Type_Loc_title 定位城市标题
    • Type_Loc_city 定位城市
    • Type_letter_index 首字母标题
    • Type_City 城市名

实现

布局

从上面的图很容易就知道,位置处于 0 ~ 3 的 ViewType 已经确定了,那如何确定城市和城市首字母索引对应位置的 ViewType 呢?简单,暴力匹配即可:

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
@Override
public int getItemViewType(int position) {
if (position == 0) {
return TYPE_SEARCH;//搜索栏
} else if (position == 1) {
return TYPE_CURRENT;//当前城市
} else if (position == 2) {
return TYPE_LOC_TITLE;//定位城市标签
} else if (position == 3) {
return TYPE_LOC_CITY;//定位城市
}

List<String> letters = new ArrayList<>();
letters.add(cityList.get(0).getSurName());
for (int i = 0; i < cityList.size(); i++) {
if (!letters.contains(cityList.get(i).getSurName())) {
letters.add(cityList.get(i).getSurName());
}
if (4 + letters.size() + i - 1 == position) {
return TYPE_LETTER;
}
if (4 + letters.size() + i == position) {
return TYPE_CITY;
}
}
return super.getItemViewType(position);
}

也就是遍历城市列表,先保存第一个城市的首字母到索引列表,然后每遍历一个城市,判断其首字母是否已经在索引列表中,存在就跳过,当前位置就是城市视图,不存在就加入首字母到索引,当前位置就是这个字母索引视图了。

这么一来,就很容易知道所有视图的数量了:

1
2
3
4
5
6
7
8
@Override
public int getItemCount() {
if (cityList == null || cityList.size() == 0) {
return 4;
}
int letterCount = getLetterCount();
return letterCount + cityList.size() + 4;
}

即总数=城市数量+字母索引的数量+顶部的几个视图。

字母索引的数量可以通过遍历城市列表获取:

1
2
3
4
5
6
7
8
9
private int getLetterCount() {
letters = new ArrayList<>();
for (City c : cityList) {
if (!letters.contains(c.getSurName())) {
letters.add(c.getSurName());
}
}
return letters.size();
}

索引冻结

列表滑动的时候,最上面的城市的首字母索引要停留在顶部,继续滑动就被下面的另一个城市列表的字母代替,这里体现为顶上去和压下来的效果,其实就是监听列表的滑动额外控制一个 View 层的滑动。
为列表设置 OnScrollListener ,在 onScrolled 方法中作出响应:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
lockTopIndex(dy);
}

private void lockTopIndex(int dy) {
mSuspensionHeight = indexViewTv.getHeight();
int pos = layoutManager.findFirstVisibleItemPosition();
hideOrShow(pos);
if (dy > 0) {//向上滑动的时候,下面的索引将上面的索引顶出去
if (adapter != null) {
View view = layoutManager.findViewByPosition(pos + 1);
if (view != null && adapter.getItemViewType(pos + 1) == CityAdapter.TYPE_LETTER) {
if (view.getTop() <= mSuspensionHeight) {
indexViewTv.setY(-(mSuspensionHeight - view.getTop()));
} else {
indexViewTv.setY(0);
}
}
}
} else {//向下滑动的时候,上面的索引将下面的索引压下来
if (adapter != null && pos >= 2) {
int type = adapter.getItemViewType(pos);
if (type == CityAdapter.TYPE_CITY
|| type == CityAdapter.TYPE_LOC_CITY) {
View view = layoutManager.findViewByPosition(pos);//目标字母索引
if (view != null) {
if (view.getBottom() >= 0 && view.getBottom() <= mSuspensionHeight) {
if (adapter.getItemViewType(pos) != adapter.getItemViewType(pos + 1)) {
//跟随目标逐渐上移
indexViewTv.setY(view.getBottom() - mSuspensionHeight);
}
} else {
//将悬浮索引归位
indexViewTv.setY(0);
}
updateIndexText(pos - 1);
}
}
}
}
if (mCurrentPosition != pos) {
mCurrentPosition = pos;
indexViewTv.setY(0);
if (dy > 0) {
updateIndexText(mCurrentPosition);
}
}
}

/**
* 根据当前可见 item 的位置判断是否要隐藏顶部悬浮索引
*
* @param pos 第一个可见 item 的位置
*/
private void hideOrShow(int pos) {
if (pos == 0 || pos == 1) {
indexViewTv.setVisibility(View.GONE);
} else {
indexViewTv.setVisibility(View.VISIBLE);
}
}

/**
* 根据 RecyclerView 的位置设置正确的悬浮索引内容
*
* @param pos 第一个可见 item 的位置
*/
private void updateIndexText(int pos) {
String s = adapter.getIndexStrFromPosition(pos);
if (s != null) {
indexViewTv.setText(s);
}
}

可以用于当做悬浮在顶部的索引的 ViewType 只有 Type_Loc_title 和 Type_letter_index ,因此需要判断首个可见 item 是否处于这两者及其知识内容的范围以内,也就是首个可见 item 是否是 Type_city 或者 Type_Current_City。
在下面一个索引距离顶部一个索引的高度的时候,将悬浮索引盖在顶部索引的上面,随着下面的索引的移动同时向上移动,即模拟被顶上去的效果,当下面这个索引完全到达顶部的时候,悬浮索引也被完全移出去了,此时再将悬浮索引盖在现在这个索引的上面,就是新的索引了。
压下来的效果同理,把悬浮索引放在当前第一个索引的顶部,随着可见索引的移动而移动,当可见的索引移动到距离顶部一个索引视图的距离的时候,停止悬浮索引的移动,就是前一个索引了。

搜索

由于搜索栏是在 adapter 中初始化的,直接在这个视图的基础操作并不方便,因此在点击搜索栏的时候由 Activity 重新操作一层 View 用于搜索交互,很多 App 也都是这么做的,包括美团,如果为了视觉体验更好,就需要添加过度动画,我这里就省了。

1
2
3
4
5
6
7
8
9
10
private void setSearchBar(SearchHolder holder) {
holder.searchBar.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Message msg = Message.obtain();
msg.what = Event.SEARCH_CITY;
EventManager.getInstance().publishEvent(msg);
}
});
}

CityActivity.class

1
2
3
4
5
6
7
8
9
10
11
@Override
public void onNewEvent(Message msg) {
if (msg.what == Event.CITY_CHOOSE_OK) {
String cityId = (String) msg.obj;
changeCurrentCity(cityId);
} else if (msg.what == Event.SEARCH_CITY) {
searchLayout.setVisibility(View.VISIBLE);
searchBar.requestFocus();
inputMethodManager.showSoftInput(searchBar, 0);
}
}

搜索栏被点击的时候,向宿主 Activity 发送一条消息,表示开启搜索交互。
searchLayout 是盖在普通视图上面的一层,不进行搜索交互的时候是隐藏的,收到消息后便显示出来。

关键字高亮

这个就比较简单了,也不需要正则匹配,简单匹配即可, SpannableStringBuilder 是可以直接作为 text 被设置的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 高亮显示列表中的搜索关键字
*
* @param searchStr 搜索关键字
* @param txt 全部文本
* @return 含高亮的文本
*/
private SpannableStringBuilder setSearchStrHighLight(String searchStr, String txt) {
SpannableStringBuilder builder = new SpannableStringBuilder(txt);
Pattern p = Pattern.compile(searchStr);
Matcher matcher = p.matcher(txt);
while (matcher.find()) {
builder.setSpan(new ForegroundColorSpan(
getResources().getColor(R.color.colorPrimary)),
matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return builder;
}

字母快速导航

也就是右侧的字母触摸导航,需要自定义 View 实现,也是个比较简单的自定义 View:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
public class LetterIndexView extends View {

private static final String TAG = "LetterIndexView";

private List<String> indexs = Arrays.asList("#", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
"U", "V", "W", "X", "Y", "Z");
private Paint paint;

private int cellWidth;
private int cellHeight;

private int curIndex = -1;
private OnIndexChangeListener mListener;
private int paddingLeft;
private int paddingRight;
private int paddingTop;
private int paddingBottom;

public void setIndexs(List<String> indexs) {
this.indexs = indexs;
invalidate();
}

public LetterIndexView(Context context) {
this(context, null);
}

public LetterIndexView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public LetterIndexView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
paint = new Paint();
paint.setColor(getResources().getColor(R.color.colorPrimary));
paint.setAntiAlias(true);
paint.setTextSize(Utils.dp2px(12));
paint.setFakeBoldText(true);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
paddingLeft = getPaddingLeft();
paddingRight = getPaddingRight();
paddingTop = getPaddingTop();
paddingBottom = getPaddingBottom();
cellWidth = getMeasuredWidth() - paddingLeft - paddingRight;
cellHeight = (getMeasuredHeight() - paddingTop - paddingBottom) / indexs.size();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
//默认宽高
setMeasuredDimension(Utils.dp2px(20), Utils.dp2px(17) * indexs.size());
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(Utils.dp2px(20), heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(widthSpecSize, Utils.dp2px(17) * indexs.size());
}
}

@Override
protected void onDraw(Canvas canvas) {
Log.d(TAG, "onDraw: ");
for (int i = 0; i < indexs.size(); i++) {
String c = indexs.get(i);
Rect bound = new Rect();
paint.getTextBounds(c, 0, c.length(), bound);
int x = (cellWidth - bound.width()) / 2 + paddingLeft;
int y = i * cellHeight + (cellHeight + bound.height()) / 2 + paddingTop;
canvas.drawText(c, x, y, paint);
}
}

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
updateIndex(event);
break;
case MotionEvent.ACTION_MOVE:
updateIndex(event);
break;
case MotionEvent.ACTION_UP:
curIndex = -1;
break;
}

return true;
}

private void updateIndex(MotionEvent event) {
int y = (int) event.getY();
int index = y / cellHeight;
if (index >= 0 && index < indexs.size()) {
if (index != curIndex) {
curIndex = index;
if (mListener != null) {
mListener.onIndexChanged(indexs.get(index));
}
}
}
}

public void setOnIndexChangeListener(OnIndexChangeListener listener) {
mListener = listener;
}

public interface OnIndexChangeListener {
void onIndexChanged(String index);
}
}

索引导航有默认的显示内容,也可以自定义重新绘制。在触摸按下和移动的时候计算出触摸的索引位置然后通过 listener 通知到宿主 Activity 更改城市列表的内容即可,功能很简单。

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
indexView.setOnIndexChangeListener(new LetterIndexView.OnIndexChangeListener() {
@Override
public void onIndexChanged(String index) {
updateCityView(index);
}
});

/**
* 根据右侧字母导航快速变换可见范围
*
* @param index 导航内容
*/
private void updateCityView(String index) {
LinearLayoutManager manager = (LinearLayoutManager) cityLayout.getLayoutManager();
if (index.equals("#")) {
manager.scrollToPositionWithOffset(0, 0);
}
if (index.equals("!")) {
manager.scrollToPositionWithOffset(2, 0);
}
if (cityList != null && cityList.size() > 0) {
//通过比较确定目标位置
List<String> list = new ArrayList<>();
int pos = 0;
for (int i = 0; i < cityList.size(); i++) {
if (!list.contains(cityList.get(i).getSurName())) {
list.add(cityList.get(i).getSurName());
pos = i;
}
if (list.get(list.size() - 1).equals(index)) {
manager.scrollToPositionWithOffset(4 + list.size() + pos - 1, 0);
}
}
}
//延迟更改顶部悬浮索引的内容,否则会在内容没有完全更新之前设置,导致索引不搭配
cityLayout.post(new Runnable() {
@Override
public void run() {
updateIndexText(layoutManager.findFirstVisibleItemPosition());
}
});
}

最终效果:

Summary

功能实现基本上就是这样,但是这样的实现方式其实并不是很好,现在都讲究组件化,这样的一个功能如果能够封装成独立的组件,即用即插,使用的方便性会很好多。但是封装涉及到页面的显示效果,城市对象的 POJO 类,要封装成符合所有 App 风格和需求就没那么容易了。