ContactList是仿通訊錄制作的一個(gè)app demo
主要技術(shù)點(diǎn)在RecyclerView励两,和自定義view
實(shí)現(xiàn)了懸浮標(biāo)題頭,導(dǎo)航側(cè)欄項(xiàng)目地址:https://github.com/hgDendi/ContactsList
歡迎star和fork
界面概覽:
![ContactsListDemo](https://ww2.sinaimg.cn/large/006tNc79gw1fbeyy1d0xhj30cb0kkaaq.jpg)
![ContactsListDemo2](https://ww2.sinaimg.cn/large/006tNc79gw1fbeyy0zjv6j30cb0kkwf7.jpg)
概要
![contactsListStructure](https://ww2.sinaimg.cn/large/006tNc79gw1fbeyy2o1jpj314w0awmy8.jpg)
如圖慎框,主要簡(jiǎn)單劃分為兩個(gè)部分:
? 數(shù)據(jù)源、與界面組件洞斯。
? 數(shù)據(jù)源主要來自手機(jī)的通訊錄信息掺逼,通過ContentResolver獲取。
? 而界面組件主要有顯示列表和側(cè)邊欄宋税。而重點(diǎn)在于列表的分組欄的繪制與現(xiàn)實(shí)摊崭,這就依靠ItemDecoration來進(jìn)行實(shí)現(xiàn)了,這也是難點(diǎn)杰赛。
復(fù)用方法
FloatingBarItemDecoration傳入需要繪制標(biāo)題欄的position和標(biāo)題String的map,目前只支持豎項(xiàng)矮台、單列的列表乏屯,如需要擴(kuò)展,請(qǐng)讀完此文瘦赫,明白原理后很容易實(shí)現(xiàn)辰晕。
IndexBar傳入Label的List,通過setListener加入勾子确虱。
FloatingBarItemDecoration
An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.
ItemDecoration主要是用來對(duì)RecyclerView進(jìn)行一些修飾含友,是對(duì)adapter數(shù)據(jù)集中的數(shù)據(jù)視圖增加修飾或空位。經(jīng)常被用來畫分割線校辩、強(qiáng)調(diào)效果窘问、可見的分組邊界等。
getItemOffset()
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
int position = ((RecyclerView.LayoutParams))
view.getLayoutParams()).getViewAdapterPosition();
outRect.set(0, mList.containsKey(position) ? mTitleHeight : 0, 0, 0);
}
繪制間距宜咒,為繪制標(biāo)題欄空出間隙惠赫。主要邏輯是通過當(dāng)前view的position判斷是否需要在上方空出矩形范圍。
onDraw()
主要是進(jìn)行靜態(tài)標(biāo)題欄等繪制故黑,即在每組view的上方儿咱,即getItemOffset()的區(qū)域進(jìn)行標(biāo)題欄的繪制。
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDraw(c, parent, state);
final int left = parent.getPaddingLeft();
final int right = parent.getWidth() - parent.getPaddingRight();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params =
(RecyclerView.LayoutParams) child.getLayoutParams();
int position = params.getViewAdapterPosition();
if (!mList.containsKey(position)) {
continue;
}
drawTitleArea(c, left, right, child, params, position);
}
}
onDrawOver
實(shí)現(xiàn)懸浮分組欄场晶,以及懸浮分組欄碰撞效果繪制混埠。
對(duì)于整個(gè)列表的繪制流程,是遵循如下的順序:
? ItemDecoration#onDraw() -> ItemView的繪制 -> ItemDecoration#onDrawOver
故而在onDrawOver中實(shí)現(xiàn)可以滿足“懸浮”诗轻,即在最上層的效果钳宪。
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
final int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
if (position == RecyclerView.NO_POSITION) {
return;
}
View child = parent.findViewHolderForAdapterPosition(position).itemView;
String initial = getTag(position);
if (initial == null) {
return;
}
//flag指示當(dāng)標(biāo)題欄是否發(fā)生碰撞(如開頭gif圖中指示的)
boolean flag = false;
if (getTag(position + 1) != null && !initial.equals(getTag(position + 1))) {
if (child.getHeight() + child.getTop() < mTitleHeight) {
//與restore()對(duì)應(yīng),表示下面translate平移坐標(biāo)系只對(duì)繪制當(dāng)前標(biāo)題欄生效
c.save();
flag = true;
//translate使發(fā)生碰撞時(shí),兩個(gè)標(biāo)題欄緊貼使套,制造出擠開的效果(dy<0,表示繪制偏下)
c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);
}
}
c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(),
parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mBackgroundPaint);
c.drawText(initial, child.getPaddingLeft() + mTextStartMargin,
parent.getPaddingTop() + mTitleHeight - (mTitleHeight - mTextHeight) / 2 - mTextBaselineOffset, mTextPaint);
if (flag) {
c.restore();
}
}
IndexBar
IndexBar是側(cè)邊欄的實(shí)現(xiàn)罐呼,是采用的自定義View的形式。
FontMatrics
在此之前侦高,介紹一個(gè)概念FontMatrics,是表征字體的一個(gè)矩陣嫉柴。
定義BaseLine為Text的起始點(diǎn)(類似英文五線譜的baseline)
drawText傳入的縱坐標(biāo)值也為BaseLine所在的縱坐標(biāo),而非矩形區(qū)域的左下角的縱坐標(biāo)(這點(diǎn)很重要,否則在開發(fā)者模式中開啟布局邊界會(huì)發(fā)現(xiàn)字體和邊界錯(cuò)亂)
主要有以下幾個(gè)屬性:
- Top (<0)
- Ascent可能的最小值(絕對(duì)值最大)
- Ascent (<0)
- 字體最高處距BaseLine的距離
- Descent (>0)
- 字體最低處距BaseLine的距離
- Bottom (>0)
- Descent可能的最大值
- Leading
- 間距奉呛,用于多行文字顯示時(shí)的距離
![fontMatrics](https://ww4.sinaimg.cn/large/006tNc79gw1fbeyy3bj60j308c02udfv.jpg)
在此例中我們用來計(jì)算每個(gè)text的高度计螺,以此作為測(cè)量View高度的參數(shù)。很多時(shí)候可以選擇不加leanding值瞧壮, 因?yàn)閱涡卸嘈袝r(shí)候的leading值都為0.(不知道什么時(shí)候可以取到非0的值)
Paint.FontMetrics fm = mPaint.getFontMetrics();
float singleHeight = fm.bottom - fm.top + fm.leading;
onMeasure()
計(jì)算View的長(zhǎng)寬登馒。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
}
private int measureWidth(int widthMeasureSpec) {
int result;
int specMode = MeasureSpec.getMode(widthMeasureSpec);
int specSize = MeasureSpec.getSize(widthMeasureSpec);
if (specMode == MeasureSpec.EXACTLY) {
result = specSize;
} else {
result = getSuggestedMinWidth();
if (specMode == MeasureSpec.AT_MOST) {
result = Math.min(result, specSize);
}
}
return result;
}
//獲取建議的最小寬度,盡量保證不會(huì)出現(xiàn)顯示不下的情況(極端情況下仍會(huì)顯示不下)
private int getSuggestedMinWidth() {
String maxLengthTag = "";
for (String tag : mNavigators) {
if (maxLengthTag.length() < tag.length()) {
maxLengthTag = tag;
}
}
return (int) (mPaint.measureText(maxLengthTag) + 0.5);
}
private int measureHeight(int heightMeasureSpec) {
int result;
int specMode = MeasureSpec.getMode(heightMeasureSpec);
int specSize = MeasureSpec.getSize(heightMeasureSpec);
if (specMode == MeasureSpec.EXACTLY) {
result = specSize;
} else {
Paint.FontMetrics fm = mPaint.getFontMetrics();
float singleHeight = fm.bottom - fm.top + fm.leading;
//這個(gè)mLetterSpacingExtra是疏密程度咆槽,是自定義屬性陈轿,默認(rèn)1.4
mBaseLineHeight = fm.bottom * mLetterSpacingExtra;
result = (int) (mNavigators.size() * singleHeight * mLetterSpacingExtra);
if (specMode == MeasureSpec.AT_MOST) {
result = Math.min(result, specSize);
}
}
return result;
}
onDraw()
負(fù)責(zé)繪制
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int height = getHeight();
int width = getWidth();
//高度為0,可能是因?yàn)閭魅雲(yún)?shù)為空秦忿,則不予顯示
if (height == 0) {
return;
}
int singleHeight = height / mNavigators.size();
//遍歷繪制Text
for (int i = 0; i < mNavigators.size(); i++) {
float xPos = width / 2 - mPaint.measureText(mNavigators.get(i)) / 2;
float yPos = singleHeight * (i + 1);
if (i == mFocusIndex) {
canvas.drawText(mNavigators.get(i), xPos, yPos - mBaseLineHeight, mFocusPaint);
} else {
canvas.drawText(mNavigators.get(i), xPos, yPos - mBaseLineHeight, mPaint);
}
}
}
DispatchTouchEvent()
處理交互事件麦射,主要是監(jiān)聽UP、CANCEL灯谣、DOWN潜秋、MOVE,其中以DOWN做為起點(diǎn)胎许,CANCEL峻呛、UP做為終點(diǎn),其他為中間狀態(tài)辜窑。以TAG的焦點(diǎn)變更和事件的開始钩述、結(jié)束做為重繪的觸發(fā)點(diǎn)。
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
final float y = event.getY();
final int formerFocusIndex = mFocusIndex;
final OnTouchingLetterChangeListener listener = mOnTouchingLetterChangeListener;
final int c = calculateOnClickItemNum(y);
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mFocusIndex = -1;
invalidate();
listener.onTouchingEnd(mNavigators.get(c));
break;
case MotionEvent.ACTION_DOWN:
listener.onTouchingStart(mNavigators.get(c));
default:
if (formerFocusIndex != c) {
if (c >= 0 && c < mNavigators.size()) {
listener.onTouchingLetterChanged(mNavigators.get(c));
mFocusIndex = c;
invalidate();
}
}
break;
}
return true;
}
/**
* @param yPos
* @return the corresponding position in list
*/
private int calculateOnClickItemNum(float yPos) {
int result;
//計(jì)算當(dāng)前觸摸點(diǎn)屬于哪個(gè)TAG,超出邊界按照邊界值返回(尤其在MOVE的時(shí)候很容易滑出邊界)
result = (int) (yPos / getHeight() * mNavigators.size());
if (result >= mNavigators.size()) {
result = mNavigators.size() - 1;
} else if (result < 0) {
result = 0;
}
return result;
}
ContactsUtils
主要是負(fù)責(zé)獲得縮寫谬擦,其中英文字符就直接獲得英文字符切距,中文字符通過比對(duì)GB2312得到英文縮寫
對(duì)于中文獲得縮寫的核心思想如下,是通過比對(duì)GB2312值得到中文中聲母惨远,繼而獲得縮寫情況谜悟。
//GB2312中簡(jiǎn)體中文的起止,判斷范圍
private static int BEGIN = 45217;
private static int END = 63486;
/**
* 各聲母第一個(gè)漢字
* {i北秽、u葡幸、v} 不做聲母
*/
private static char[] chartable = {'啊', '芭', '擦', '搭', '蛾', '發(fā)', '噶', '哈', '擊', '喀', '垃','媽', '拿', '哦', '啪', '期', '然', '撒', '塌', '挖', '昔', '壓', '匝'};
private static char[] initialtable = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K','L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Y', 'Z'};
//此table是各聲母對(duì)應(yīng)的起始GB值,與initialtable對(duì)應(yīng)
private static int[] table = new int[chartable.length + 1];
static {
for (int i = 0; i < chartable.length; i++) {
table[i] = gbValue(chartable[i]);
}
table[chartable.length] = END;
}
//計(jì)算char對(duì)應(yīng)的gb值
private static int gbValue(char ch) {
String str = "" + ch;
try {
byte[] bytes = str.getBytes("GB2312");
if (bytes.length < 2) {
return 0;
}
return (bytes[0] << 8 & 0xff00) + (bytes[1] & 0xff);
} catch (Exception e) {
return 0;
}
}
ContactsManager
負(fù)責(zé)通訊錄信息的獲取贺氓,此處只取了電話號(hào)碼和聯(lián)系人名稱蔚叨,使用的是ContentResolver進(jìn)行查詢
@NonNull
public static ArrayList<ShareContactsBean> getPhoneContacts(Context mContext) {
ArrayList<ShareContactsBean> result = new ArrayList<>(0);
ContentResolver resolver = mContext.getContentResolver();
Cursor phoneCursor = resolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
new String[]{ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME}, null, null, null);
if (phoneCursor != null) {
while (phoneCursor.moveToNext()) {
String phoneNumber = phoneCursor.getString(0).replace(" ", "");
String contactName = phoneCursor.getString(1);
result.add(new ShareContactsBean(contactName, phoneNumber));
}
phoneCursor.close();
}
//對(duì)結(jié)果進(jìn)行排序,這個(gè)排序方法寫在bean中
Collections.sort(result, new Comparator<ShareContactsBean>() {
@Override
public int compare(ShareContactsBean l, ShareContactsBean r) {
return l.compareTo(r);
}
});
return result;
}