自定義實現微信通訊錄效果View
歡迎訪問我的個人獨立部落格 ittiger.cn,原創文章,未經允許不得隨意轉載。
前言
在使用App過程中,經常會有使用到聯絡人或城市列表的場景,其實這兩種效果是一樣的,都是右邊有個索引列表,點選索引列表可跳轉到指定字母開頭的聯絡人或城市上去,同時向上滑動過程中頭部會有個顯示當前聯絡人首字母的介面固定不動。下面我以微信通訊錄的效果作為例子,介紹我是如何實現一個這樣效果自定義View的思路和過程。
實現效果
思路分析
既然要高仿實現微信通訊錄的效果,那我們來先看看微信通訊錄的效果
微信通訊錄效果分析
通過對微信通訊的效果進行分析之後,得出如下幾點:
1. 通訊錄展示分為兩部分:
2. 主體:聯絡人列表
3. 索引條:右邊字母索引條
2. 主體聯絡人列表又分為三部分:
3. 聯絡人姓名和頭像展示
4. 根據聯絡人姓名的首字母進行分組,每組開頭都會顯示組名稱(首字母),並按字母順序排序
5. 列表開頭的四個選單,可以看成是一個特殊組資料,但是無組名稱展示
3. 索引條中的內容與聯絡人列表中的所有組字母一樣,同時點選索引條中的字母會將列表定位到當前字母所代表的聯絡人組,因此索引條的每個字母會與該字母所代表聯絡人組的位置有個一一對應關係
4. 點選索引條中的↑
↑
與四個選單作為一組對應關係,只是該選單組無組名稱 5. 點選索引條的某個字母時,列表中間會有該字母的提示顯示
5. 列表向上滑動時當前組的組名稱固定在列表頭部顯示不動,直到下一組組名稱滑動到頂部時,原來固定不動的組名稱檢視開始往上滑出,下一組組名稱頂替上去
實現思路分析
- 既然要展示聯絡人列表,所以我採用
RecyclerView
- 滑動過程中頂部會有個檢視固定不動,而
RecyclerView
滑動時不可能有子檢視固定不動,因此我採用一個獨立的檢視View
來作為這個固定不動的頂部檢視顯示組名稱,在滑動過程中控制該View
的顯示和隱藏及其內容的變化,顯然這個時候就需要父檢視將RecyclerView
View
包裝起來 - 索引條因為是一個字母索引列表,因此我採用自定義
View
來繪製這些字母,在繪製過程中每個字母在索引條中要水平居中,而當列表頭部有固定顯示某個組名稱(字母)時,索引條中對應的字母會有一個紅色的圓作為該字母的背景,同時字母在圓中居中顯示 - 點選索引條的字母時,列表中間出現的字母提示也採用一個獨立的
View
顯示,並將該View
放到與RecyclerView
所處的同一父檢視。 - 索引條中的每個字母都需要與列表中對應組所在的位置索引有個一一對應的關係
- 因為微信通訊錄中頂部的四個選單與其他聯絡人具有不同的行為和展示方式,因此最終實現的
View
需要支援自定義顯示不同的頭部檢視及對應的索引字母
實現細節
下面我介紹下我在實現過程中的一些要點
資料處理
使用者資料
一般應用在實現過程中,拿到的只有具體的聯絡人資料,而沒有聯絡人對應的首字母,如果說我們自定義的View
需要開發者將聯絡人對應的首字母也傳進來,那這個自定義View
寫的也太lower了,對開發者太不友好了。因此我們最終實現的View
所需要的資料就是開發者能拿到的聯絡人資料即可,這樣的話就需要我們在實現View
的過程中將開發者傳遞過來的聯絡人資料進行處理,然後得到我們在RecyclerView
中展示的實際資料(聯絡人+字母索引)。
既然要對聯絡人資料進行處理得到該聯絡人的首字母,所以我定義了一個實體介面,所有的聯絡人資料實體必須實現這個介面以便告知我們需要對那個資料欄位進行處理得到其索引首字母,具體介面如下:
public interface BaseEntity {
/**
* 要索引的欄位資料資訊,例如聯絡人中對姓名進行索引,則此處返回姓名欄位值
* @return
*/
String getIndexField();
}
展示資料
列表在展示過程中有兩種型別資料,一種是聯絡人資料,一種是聯絡人所在組的組名稱(索引值),所以最終在RecyclerView
中進行展示時使用使用者資料實體BaseEntity
是無法達到這種展示效果的,因此我定義了一個RecyclerView
實際展示資料的實體類,如下:
public class IndexStickyEntity<T> {
/**
* 當前資料項的型別,自動轉換賦值
*/
private int mItemType = ItemType.ITEM_TYPE_CONTENT;
/**
* 當前資料的索引值,自動轉換賦值(索引條中顯示的文字)
*/
private String mIndexValue;
/**
* 索引檢視顯示的索引名稱(組名稱)
*/
private String mIndexName;
/**
* 原始資料,使用者實際展示的資料,用於檢視的繫結
* 當次值為null時,則表示此實體代表一個Index資料
* T extends BaseEntity
*/
private T mOriginalData;
/**
* 當前資料項的拼音
*/
private String mPinYin;
... setter & getter
}
public class ItemType {
/**
* 列表中普通資料項型別,例如聯絡人列表中的:聯絡人資訊項
*/
public static final int ITEM_TYPE_CONTENT = 1000000;
/**
* 列表中索引項型別,例如聯絡人列表中的:A,B,C...等索引資料
*/
public static final int ITEM_TYPE_INDEX = 2000000;
/**
* 列表中增加頭部索引資料(如自定義的常用聯絡人)
*/
public static final int ITEM_TYPE_INDEX_HEADER = 3000000;
/**
* 列表中增加底部索引資料
*/
public static final int ITEM_TYPE_INDEX_FOOTER = 4000000;
}
使用者資料 –>展示資料
在拿到使用者的聯絡人資料後,進行轉換處理得到真實展示資料,實現如下,程式碼中註釋比較清晰,就不一一解釋其實現邏輯了:
public class ConvertHelper {
/**
* 轉換過程中,如果待索引欄位資訊為非字母串,則將其索引值設為:#
*/
public static final String INDEX_SPECIAL = "#";
public static class ConvertResult<T> {
//轉換後得到的實際展示資料列表,包括聯絡人資料+組名稱資料(索引名稱)
private List<IndexStickyEntity<T>> mIndexStickyEntities = new ArrayList<>();
//索引條中展示的資料列表
private List<String> mIndexValueList = new ArrayList<>();
//索引條中展示資料與對應組在列表中位置索引的一一對映
private Map<String, Integer> mIndexValuePositionMap = new HashMap<>();
public List<IndexStickyEntity<T>> getIndexStickyEntities() {
return mIndexStickyEntities;
}
public List<String> getIndexValueList() {
return mIndexValueList;
}
public Map<String, Integer> getIndexValuePositionMap() {
return mIndexValuePositionMap;
}
}
//拿到資料後呼叫此方法進行資料轉換處理
public static <T extends BaseEntity> ConvertResult<T> transfer(List<T> list) {
ConvertResult<T> convertResult = new ConvertResult<T>();
//使用TreeMap自動按照Key(字母索引值)進行排序
TreeMap<String, List<IndexStickyEntity<T>>> treeMap = new TreeMap<>(ComparatorFactory.indexValueComparator());
for(int i = 0; i < list.size(); i++) {
IndexStickyEntity<T> entity = originalEntityToIndexEntity(list.get(i));
if(treeMap.containsKey(entity.getIndexValue())) {//Map中已存在此索引值
treeMap.get(entity.getIndexValue()).add(entity);
} else {
List<IndexStickyEntity<T>> indexStickyEntities = new ArrayList<>();
indexStickyEntities.add(entity);
treeMap.put(entity.getIndexValue(), indexStickyEntities);
}
}
for(String indexValue : treeMap.keySet()) {
//建立組名稱展示資料實體
IndexStickyEntity<T> indexValueEntity = createIndexEntity(indexValue, indexValue);
//將索引值新增到索引值列表中
convertResult.getIndexValueList().add(indexValue);
//按順序將索引實體新增到列表中
convertResult.getIndexStickyEntities().add(indexValueEntity);
//將索引值與索引值在結果列表中的位置進行對映
convertResult.getIndexValuePositionMap().put(indexValue, convertResult.getIndexStickyEntities().size() - 1);
//得到當前索引值下的索引資料實體
List<IndexStickyEntity<T>> indexStickyEntities = treeMap.get(indexValue);
//對資料實體按自然進行排序
Collections.sort(indexStickyEntities, ComparatorFactory.<T>indexEntityComparator());
//將排序後的實體列表按順序加入到結果列表中
convertResult.getIndexStickyEntities().addAll(indexStickyEntities);
}
return convertResult;
}
/**
* 原始資料轉換成展示的索引資料
* @param originalEntity
* @param <T>
* @return
*/
public static <T extends BaseEntity> IndexStickyEntity<T> originalEntityToIndexEntity(T originalEntity) {
IndexStickyEntity<T> entity = new IndexStickyEntity<>();
T item = originalEntity;
String indexFieldName = item.getIndexField();
String pinyin = PinYinHelper.getPingYin(indexFieldName);
String indexValue;
if(PinYinHelper.isLetter(pinyin)) {//首字元是否為字母
indexValue = pinyin.substring(0, 1).toUpperCase();
} else {//非字母以#代替
indexValue = INDEX_SPECIAL;
}
entity.setPinYin(pinyin);
entity.setOriginalData(item);
entity.setIndexValue(indexValue);
entity.setIndexName(indexValue);
return entity;
}
/**
* 根據索引值建立索引實體物件
* @param indexValue
* @param <T>
* @return
*/
public static <T extends BaseEntity> IndexStickyEntity<T> createIndexEntity(String indexValue, String indexName) {
//根據索引值建立索引實體物件
IndexStickyEntity<T> indexValueEntity = new IndexStickyEntity<>();
indexValueEntity.setIndexValue(indexValue);
indexValueEntity.setPinYin(indexValue);
indexValueEntity.setIndexName(indexName);
indexValueEntity.setItemType(ItemType.ITEM_TYPE_INDEX);
return indexValueEntity;
}
}
SideBar實現
SideBar繪製
- 初始化
SideBar
相關繪製引數 - 根據索引列表計算
SideBar
的實際高度,並得到SideBar
的最終高度 - 根據
SideBar
高度計算其每項的高度 - 繪製所有的索引值到檢視上,並根據選中情況繪製當前選項的圓形背景
關鍵程式碼如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int height = MeasureSpec.getSize(heightMeasureSpec);
if (mValueList.size() > 0) {
//計算SideBar的實際高度
mCalViewHeight = (int) (((mValueList.size() - 1) * mTextPaint.getTextSize() + mFocusTextPaint.getTextSize()) + (mValueList.size() + 1) * mTextSpace);
}
if (mCalViewHeight > height) {//實際高度超過可用高度
mCalViewHeight = height;
}
super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(mCalViewHeight, MeasureSpec.EXACTLY));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if(mValueList.size() == 0) {
return;
}
//計算每項的高度
mItemHeight = ((float) getHeight()) / mValueList.size();
float radius = Math.min(getWidth() / 2, mItemHeight / 2);//選中狀態時圓形背景半徑
for(int i = 0; i < mValueList.size(); i++) {
if(mSelectPosition == i) {
//計算文字垂直居中的基準線
float baseline = mItemHeight / 2 + (mFocusTextPaint.getFontMetrics().descent - mFocusTextPaint.getFontMetrics().ascent) / 2 - mFocusTextPaint.getFontMetrics().descent;
canvas.drawCircle(getWidth() / 2, mItemHeight / 2 + mItemHeight * i, radius, mFocusTextBgPaint);
canvas.drawText(mValueList.get(i), getWidth() / 2, baseline + mItemHeight * i, mFocusTextPaint);
} else {
float baseline = mItemHeight / 2 + (mTextPaint.getFontMetrics().descent - mTextPaint.getFontMetrics().ascent) / 2 - mTextPaint.getFontMetrics().descent;
canvas.drawText(mValueList.get(i), getWidth() / 2, baseline + mItemHeight * i, mTextPaint);
}
}
}
點選SideBar選中
SideBar
繪製成功後,在使用過程中還有一個重要的場景需要實現,那就是我們點選SideBar
的時候要知道我們當前點選的是SideBar
中的哪個選項,具體實現思路是這樣的:根據當前觸控的y座標(其實是相對於檢視座標系)和每個選項的高度計算當前觸控點在哪個選項內,具體實現程式碼如下:
@Override
public boolean onTouch(View v, MotionEvent event) {
int touchPosition = getPositionForPointY(event.getY());
if(touchPosition < 0 || touchPosition >= mValueList.size()) {
return true;
}
if(mOnSideBarTouchListener != null) {
//此介面監聽主要用於列表跳轉到對應的組
mOnSideBarTouchListener.onSideBarTouch(v, event, touchPosition);
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
if(touchPosition != mSelectPosition) {
setSelectPosition(touchPosition);//設定選中
}
break;
}
return true;
}
/**
* 根據點選的y座標計算得到當前選中的是哪個選項
* @param pointY
* @return 沒選中則返回-1
*/
private int getPositionForPointY(float pointY) {
if(mValueList.size() <= 0) {
return -1;
}
//根據手按下的縱座標與每個選項的高度計算當前所在項的索引
int position = (int) (pointY / mItemHeight);
if(position < 0) {
position = 0;
} else if(position > mValueList.size() - 1) {
position = mValueList.size() - 1;
}
return position;
}
點選SideBar某項時跳轉列表到對應組
@Override
public void onSideBarTouch(View v, MotionEvent event, int touchPosition) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
if(touchPosition != mSideBar.getSelectPosition()) {
if(touchPosition == 0) {
mLinearLayoutManager.scrollToPosition(0);
} else {
int recyclerViewPosition = getScrollPositionBySideBarSelectPosition(touchPosition);
mLinearLayoutManager.scrollToPositionWithOffset(recyclerViewPosition, 0);
}
}
break;
}
}
固定頭部檢視處理
頭部顯示邏輯
- 因為固定不動的頭部檢視(暫且叫做
mStickyHeaderView
其檢視實現與組名稱檢視完全一樣)是在RecyclerView
的上面,所以當其顯示時會遮蓋掉RecyclerView
的第一個可見項。——這個點很重要 - 獲取
RecyclerView
的第一個可見項的實體資料IndexStickyEntity
- 如果當前資料的組名稱為空,則不顯示頭部檢視
mStickyHeaderView
,要注意的是前面我在轉換資料的時候會給所有普通聯絡人實體物件都會設定組名稱(如果存在) - 如果當前資料的組名稱不為空,則顯示頭部檢視
mStickyHeaderView
並同時更新其顯示內容
- 如果當前資料的組名稱為空,則不顯示頭部檢視
- 滾動過程中獲取
RecyclerView
列表中的第二個可見項的實體資料IndexStickyEntity
,比如叫做:secondVisibleEntity
- 如果
secondVisibleEntity.getItemType() == ItemType.ITEM_TYPE_INDEX
,即為組名稱檢視(索引檢視),此時說明第二組資料已經滾動上來了,需要將固定在頭部的mStickyHeaderView
檢視隨著滾動操作慢慢的滑出介面變成不可見,同時secondVisibleEntity
則會慢慢滾動到mStickyHeaderView
原來所在的位置,此時在介面上看著就像是第二組的組名稱檢視慢慢的替換了固定在頂部的mStickyHeaderView
- 如果
secondVisibleEntity.getItemType() != ItemType.ITEM_TYPE_INDEX
則需要將mStickyHeaderView
恢復到初始位置。因為當secondVisibleEntity
滾動到mStickyHeaderView
原來所在的位置後,此時第一個可見項變成了secondVisibleEntity
,而此時的第二個可見項則變成了普通的聯絡人檢視比如叫mContactView
,而此時列表還會繼續往上滾動,隨著滾動secondVisibleEntity
會慢慢的變成不可見,而mStickyHeaderView
已經滑出介面不可見了,所以當secondVisibleEntity.getItemType() != ItemType.ITEM_TYPE_INDEX
則需要將mStickyHeaderView
恢復到初始位置顯示新的組名稱。
- 如果
- 列表滾動過程中還需要根據第一個可見項的索引值更新索引條
SideBar
的選中項
滾動時頭部顯示邏輯實現
class RecyclerViewScrollListener extends RecyclerView.OnScrollListener {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
int firstVisiblePosition = mLinearLayoutManager.findFirstVisibleItemPosition();
if(firstVisiblePosition < 0 || firstVisiblePosition >= mAdapter.getItemCount()) {
return;
}
IndexStickyEntity entity = mAdapter.getItem(firstVisiblePosition);
mSideBar.setSelectPosition(mSideBar.getPosition(entity.getIndexValue()));
if(TextUtils.isEmpty(entity.getIndexName()) && mStickyHeaderView.itemView.getVisibility() == VISIBLE) {
//如果當前第一個可見項的索引值為空,則當前項可能是普通檢視,非索引檢視,因此此時需要將mStickyHeaderView進行隱藏
mStickyIndexValue = null;
mStickyHeaderView.itemView.setVisibility(INVISIBLE);
} else {//第一個可見項為索引檢視,則需要顯示頭部固定的索引提示檢視
showStickyHeaderView(entity.getIndexName(), firstVisiblePosition);
}
if(firstVisiblePosition + 1 >= mAdapter.getItemCount()) {
return;
}
//獲取第二個可見項實體物件
IndexStickyEntity secondVisibleEntity = mAdapter.getItem(firstVisiblePosition + 1);
if(secondVisibleEntity.getItemType() == ItemType.ITEM_TYPE_INDEX) {
//第二個可見項是索引值檢視
View secondVisibleItemView = mLinearLayoutManager.findViewByPosition(firstVisiblePosition + 1);
if(secondVisibleItemView.getTop() <= mStickyHeaderView.itemView.getHeight() && mStickyIndexValue != null) {
//當secondVisibleItemView距頂部的距離 <= mStickyHeaderView的高度時,mStickyHeaderView開始往上滑出
mStickyHeaderView.itemView.setTranslationY(secondVisibleItemView.getTop() - mStickyHeaderView.itemView.getHeight());
}
} else {
//第二個可見項不是索引值檢視
if(mStickyHeaderView.itemView.getTranslationY() != 0) {//有偏移
mStickyHeaderView.itemView.setTranslationY(0);
}
}
}
}
喜歡的同學歡迎Star和fork
write by laohu
2016年12月31日