又是很久沒有寫文章了誊酌,不寫文章的這段日子里,感覺生活毫無樂趣露乏,沒有什么成就感碧浊,以后還是要多寫啊,至少一周一篇吧瘟仿。
需求
城市選擇頁面是很多 App 都有的組件箱锐,比如美團(tuán)、大眾點(diǎn)評之類的劳较,而這個(gè)文章就是模仿美團(tuán)的城市選擇組件打造的驹止,不過比起美團(tuán)還是有差距的浩聋。
主要的需求有以下幾點(diǎn):
- 顯示當(dāng)前城市;
- 顯示設(shè)備定位城市幢哨;
- 按照城市拼音進(jìn)行排序和分類
- 城市首字母快速導(dǎo)航
- 城市搜索赡勘,關(guān)鍵字高亮
由于顯示定位城市需要使用到第三方地圖 SDK,為了專注的實(shí)現(xiàn)界面效果捞镰,這里就不具體實(shí)現(xiàn)了闸与,模擬一下即可。
設(shè)計(jì)
根據(jù)需求來看岸售,城市選擇頁面可以分為這么幾個(gè)部分:
- 搜索欄
- 城市列表
- 首字母索引導(dǎo)航
- 搜索結(jié)果列表
為了更好的利用屏幕空間践樱,把搜索欄與城市列表放在一起,也就是在同一個(gè) RecyclerView 中凸丸。
差不多就是下面的樣子:
- 由于列表包含不同的布局拷邢,需要定義多個(gè) ViewType
- Type_Search 搜索欄
- Type_Current 當(dāng)前城市
- Type_Loc_title 定位城市標(biāo)題
- Type_Loc_city 定位城市
- Type_letter_index 首字母標(biāo)題
- Type_City 城市名
實(shí)現(xiàn)
布局
從上面的圖很容易就知道,位置處于 0 ~ 3 的 ViewType 已經(jīng)確定了屎慢,那如何確定城市和城市首字母索引對應(yīng)位置的 ViewType 呢瞭稼?簡單,暴力匹配即可:
@Override
public int getItemViewType(int position) {
if (position == 0) {
return TYPE_SEARCH;//搜索欄
} else if (position == 1) {
return TYPE_CURRENT;//當(dāng)前城市
} else if (position == 2) {
return TYPE_LOC_TITLE;//定位城市標(biāo)簽
} 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);
}
也就是遍歷城市列表腻惠,先保存第一個(gè)城市的首字母到索引列表环肘,然后每遍歷一個(gè)城市,判斷其首字母是否已經(jīng)在索引列表中集灌,存在就跳過悔雹,當(dāng)前位置就是城市視圖,不存在就加入首字母到索引欣喧,當(dāng)前位置就是這個(gè)字母索引視圖了腌零。
這么一來,就很容易知道所有視圖的數(shù)量了:
@Override
public int getItemCount() {
if (cityList == null || cityList.size() == 0) {
return 4;
}
int letterCount = getLetterCount();
return letterCount + cityList.size() + 4;
}
即總數(shù)=城市數(shù)量+字母索引的數(shù)量+頂部的幾個(gè)視圖唆阿。
字母索引的數(shù)量可以通過遍歷城市列表獲纫娼А:
private int getLetterCount() {
letters = new ArrayList<>();
for (City c : cityList) {
if (!letters.contains(c.getSurName())) {
letters.add(c.getSurName());
}
}
return letters.size();
}
索引凍結(jié)
列表滑動(dòng)的時(shí)候,最上面的城市的首字母索引要停留在頂部驯鳖,繼續(xù)滑動(dòng)就被下面的另一個(gè)城市列表的字母代替饰躲,這里體現(xiàn)為頂上去和壓下來的效果,其實(shí)就是監(jiān)聽列表的滑動(dòng)額外控制一個(gè) View 層的滑動(dòng)臼隔。
為列表設(shè)置 OnScrollListener ,在 onScrolled 方法中作出響應(yīng):
@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) {//向上滑動(dòng)的時(shí)候妄壶,下面的索引將上面的索引頂出去
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 {//向下滑動(dòng)的時(shí)候摔握,上面的索引將下面的索引壓下來
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);//目標(biāo)字母索引
if (view != null) {
if (view.getBottom() >= 0 && view.getBottom() <= mSuspensionHeight) {
if (adapter.getItemViewType(pos) != adapter.getItemViewType(pos + 1)) {
//跟隨目標(biāo)逐漸上移
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);
}
}
}
/**
* 根據(jù)當(dāng)前可見 item 的位置判斷是否要隱藏頂部懸浮索引
*
* @param pos 第一個(gè)可見 item 的位置
*/
private void hideOrShow(int pos) {
if (pos == 0 || pos == 1) {
indexViewTv.setVisibility(View.GONE);
} else {
indexViewTv.setVisibility(View.VISIBLE);
}
}
/**
* 根據(jù) RecyclerView 的位置設(shè)置正確的懸浮索引內(nèi)容
*
* @param pos 第一個(gè)可見 item 的位置
*/
private void updateIndexText(int pos) {
String s = adapter.getIndexStrFromPosition(pos);
if (s != null) {
indexViewTv.setText(s);
}
}
可以用于當(dāng)做懸浮在頂部的索引的 ViewType 只有 Type_Loc_title 和 Type_letter_index ,因此需要判斷首個(gè)可見 item 是否處于這兩者及其知識(shí)內(nèi)容的范圍以內(nèi)丁寄,也就是首個(gè)可見 item 是否是 Type_city 或者 Type_Current_City氨淌。
在下面一個(gè)索引距離頂部一個(gè)索引的高度的時(shí)候泊愧,將懸浮索引蓋在頂部索引的上面,隨著下面的索引的移動(dòng)同時(shí)向上移動(dòng)盛正,即模擬被頂上去的效果删咱,當(dāng)下面這個(gè)索引完全到達(dá)頂部的時(shí)候,懸浮索引也被完全移出去了豪筝,此時(shí)再將懸浮索引蓋在現(xiàn)在這個(gè)索引的上面痰滋,就是新的索引了。
壓下來的效果同理续崖,把懸浮索引放在當(dāng)前第一個(gè)索引的頂部敲街,隨著可見索引的移動(dòng)而移動(dòng),當(dāng)可見的索引移動(dòng)到距離頂部一個(gè)索引視圖的距離的時(shí)候严望,停止懸浮索引的移動(dòng)多艇,就是前一個(gè)索引了。
搜索
由于搜索欄是在 adapter 中初始化的像吻,直接在這個(gè)視圖的基礎(chǔ)操作并不方便峻黍,因此在點(diǎn)擊搜索欄的時(shí)候由 Activity 重新操作一層 View 用于搜索交互,很多 App 也都是這么做的拨匆,包括美團(tuán)姆涩,如果為了視覺體驗(yàn)更好,就需要添加過度動(dòng)畫涮雷,我這里就省了阵面。
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
@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);
}
}
搜索欄被點(diǎn)擊的時(shí)候,向宿主 Activity 發(fā)送一條消息洪鸭,表示開啟搜索交互样刷。
searchLayout 是蓋在普通視圖上面的一層,不進(jìn)行搜索交互的時(shí)候是隱藏的览爵,收到消息后便顯示出來置鼻。
關(guān)鍵字高亮
這個(gè)就比較簡單了,也不需要正則匹配蜓竹,簡單匹配即可, SpannableStringBuilder 是可以直接作為 text 被設(shè)置的:
/**
* 高亮顯示列表中的搜索關(guān)鍵字
*
* @param searchStr 搜索關(guān)鍵字
* @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;
}
字母快速導(dǎo)航
也就是右側(cè)的字母觸摸導(dǎo)航箕母,需要自定義 View 實(shí)現(xiàn),也是個(gè)比較簡單的自定義 View:
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) {
//默認(rèn)寬高
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);
}
}
索引導(dǎo)航有默認(rèn)的顯示內(nèi)容俱济,也可以自定義重新繪制嘶是。在觸摸按下和移動(dòng)的時(shí)候計(jì)算出觸摸的索引位置然后通過 listener 通知到宿主 Activity 更改城市列表的內(nèi)容即可,功能很簡單蛛碌。
indexView.setOnIndexChangeListener(new LetterIndexView.OnIndexChangeListener() {
@Override
public void onIndexChanged(String index) {
updateCityView(index);
}
});
/**
* 根據(jù)右側(cè)字母導(dǎo)航快速變換可見范圍
*
* @param index 導(dǎo)航內(nèi)容
*/
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) {
//通過比較確定目標(biāo)位置
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);
}
}
}
//延遲更改頂部懸浮索引的內(nèi)容聂喇,否則會(huì)在內(nèi)容沒有完全更新之前設(shè)置,導(dǎo)致索引不搭配
cityLayout.post(new Runnable() {
@Override
public void run() {
updateIndexText(layoutManager.findFirstVisibleItemPosition());
}
});
}
最終效果:
Summary
功能實(shí)現(xiàn)基本上就是這樣,但是這樣的實(shí)現(xiàn)方式其實(shí)并不是很好希太,現(xiàn)在都講究組件化克饶,這樣的一個(gè)功能如果能夠封裝成獨(dú)立的組件,即用即插誊辉,使用的方便性會(huì)很好多矾湃。但是封裝涉及到頁面的顯示效果,城市對象的 POJO 類堕澄,要封裝成符合所有 App 風(fēng)格和需求就沒那么容易了邀跃。
本文最早發(fā)布于alphagao.com