概述
現(xiàn)在很多的APP項目都會集成IM功能(IM的好處和優(yōu)點我就不說了,畢竟本文的重點不是介紹IM),說到IM肯定就會有一個好友列表,說到好友列表肯定就會有一個列表索引,說到列表索引就肯定會有...(好像說過頭了@A@);
當(dāng)然這里的索引列表不止是用于好友列表中,一般的手機(jī)文件夾列表(參考魅族),項目文檔排序(我的項目中使用到了)等涉及到列表的都可以使用索引欄來快速的定位,按照慣例接下來應(yīng)該是效果圖的時間了(這里給出Demo的效果圖):


實現(xiàn)思路
效果圖顯示的還可以,功能需求我就不講解了,用過微信、手機(jī)通訊錄的都知道;
這里使用的直接是英文字符,漢字的話需要將漢字轉(zhuǎn)換為拼音,如果使用的是環(huán)信SDK,它自帶有一個漢字轉(zhuǎn)拼音的工具類,也可以使用 Pinyin.jar 等第三方j(luò)ar包來轉(zhuǎn)換;
自定義控件的選擇
索引欄主要模塊為字符索引欄和字符彈窗,可以自定義一個ViewGroup來包含兩個模塊,也可以在一個View里面直接繪制兩個模塊,當(dāng)然我肯定會選擇后者(別問我為什么就是不想回答);繪制繪制分析
我們可以把索引欄分成三個部分來繪制:
1.字符欄的背景效果繪制;
2.字符欄的字符繪制;
3.字符彈窗的繪制;
我們可以定義三個方法來繪制三個不同的模塊,只需要在onDraw()中調(diào)用即可;觸摸事件分析
根據(jù)效果圖分析,索引欄控件是覆蓋在ListView上方,只有down事件在字符索引上才會處理此次事件,因此這里需要對onTouchEvent()方法進(jìn)行處理;
代碼實現(xiàn)
代碼模塊我們只講解重要的代碼,文章的最后會給出代碼的地址;
-
步驟一:自定義相關(guān)屬性
我們首先需要創(chuàng)建一個類SideBar繼承自View,然后定義自定義屬性,這里我們把背景、文字等屬性都設(shè)定為自定義屬性,因此自定義屬性的字段會比較多:
<!-- 自定義索引欄控件的屬性 -->
<declare-styleable name="SideBar">
<!-- 索引欄的寬度 -->
<attr name="sideBarWidth" format="dimension|reference" />
<!-- 索引欄的內(nèi)外的上下間距 -->
<attr name="sideBarMarginTop" format="dimension|reference" />
<attr name="sideBarMarginBottom" format="dimension|reference" />
<attr name="sideBarPaddingTop" format="dimension|reference" />
<attr name="sideBarPaddingBottom" format="dimension|reference" />
<!-- 索引欄的位置 -->
<attr name="sideBarPosition" format="enum">
<enum name="LEFT" value="0" />
<enum name="RIGHT" value="1" />
</attr>
<!-- 索引欄與左邊/右邊的間距 -->
<attr name="sideBarSpacing" format="dimension|reference" />
<!-- 索引欄默認(rèn)的背景顏色 -->
<attr name="sideBarNormalColor" format="color|reference" />
<!-- 索引欄按壓的背景顏色 -->
<attr name="sideBarPressColor" format="color|reference" />
<!-- 索引欄默認(rèn)的背景色是否顯示 -->
<attr name="showSideBarNormalColor" format="boolean" />
<!-- 索引欄上下兩端的形狀 -->
<attr name="sideBarCap" format="enum">
<enum name="NORMAL" value="0" />
<enum name="ROUND" value="1" />
</attr>
<!-- 索引欄上字符的大小 -->
<attr name="sideBarLetterSize" format="dimension|reference" />
<!-- 索引欄上字符默認(rèn)的顏色 -->
<attr name="sideBarLetterNormalColor" format="color|reference" />
<!-- 索引欄上被選中字符的顏色 -->
<attr name="sideBarLetterSelectColor" format="color|reference" />
<!-- 索引欄上默認(rèn)字符的寬度 -->
<attr name="sideBarLetterNormalWidth" format="dimension|reference" />
<!-- 索引欄被按壓時,字符的寬度 -->
<attr name="sideBarLetterPressWidth" format="dimension|reference" />
<!-- 彈窗的背景顏色 -->
<attr name="dialogColor" format="color|reference" />
<!-- 彈窗寬度與控件寬度的比例 -->
<attr name="dialogSizePercent" format="float" />
<!-- 彈窗的形狀 -->
<attr name="dialogShape" format="enum">
<enum name="CIRCLE" value="0" />
<enum name="SQUARE" value="1" />
</attr>
<!-- 彈窗為方形時的圓角 -->
<attr name="dialogCorner" format="dimension|reference" />
<!-- 彈窗字符文字的大小 -->
<attr name="dialogLetterSize" format="dimension|reference" />
<!-- 彈窗字符文字的顏色 -->
<attr name="dialogLetterColor" format="color|reference" />
<!-- 彈窗字符的寬度 -->
<attr name="dialogLetterWidth" format="dimension|reference" />
<!-- 彈窗的位置是否固定垂直居中 -->
<attr name="dialogIsFixed" format="boolean" />
<!-- 彈窗中心在控件的水平位置 -->
<attr name="dialogHorizontalPercent" format="float" />
</declare-styleable>
這里的自定義屬性分別對應(yīng)上述繪制分析的三點,基本上每個屬性我都給出了注釋,也很好理解,這里的自定義屬性中包含 enum 類型,因此在接收屬性的時最好也要轉(zhuǎn)換成 enum 類型,例如:
/**
* 定義索引欄兩端的樣式枚舉
*/
public enum SideBarCap {
NORMAL(0), ROUND(1);
private int mIndex;
SideBarCap(int index) {
mIndex = index;
}
public int getIndex() {
return mIndex;
}
}
// ------------------------
// 將接收到的枚舉屬性的值(int)轉(zhuǎn)換成枚舉類型
mSideBarCap = SideBarCap
.values()[typedArray.getInt(R.styleable.SideBar_sideBarCap, SideBarCap.ROUND.getIndex())];
-
步驟二:初始化相關(guān)屬性
這個步驟最關(guān)鍵的地方在于索引欄每個字符的mItemHeight的計算,由于一開始給定的類型是int而這里的字符數(shù)量有 20+,每個字符如果都相差0.5,造成的誤差就會比較大,最終導(dǎo)致字符在索引欄上下不對稱,因此這里的mItemHeight推薦使用float類型;
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mHeight = h;
// 計算每個字符的高度,mItemHeight 使用 float類型
mItemHeight = (mHeight - mSideBarMarginTop - mSideBarMarginBottom - mSideBarPaddingTop - mSideBarPaddingBottom) / (float) mLetters.length;
}
這里的字符高度是在onSizeChange()方法中計算的,因此如果在Java代碼中想要修改字符數(shù)組的數(shù)量、索引欄的高度等和 mItemHeight 值計算相關(guān)的參數(shù),都需要調(diào)用 requestLayout() 方法重新計算mItemHeight,而不是調(diào)用 invalidate();
-
步驟三:重寫onDraw()方法
根據(jù)上面的繪制分析,這里把三個部分的繪制抽取成三個方法;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 繪制索引欄背景
drawSideBar(canvas);
// 繪制索引欄字符
drawSideBarLetter(canvas);
// 繪制彈窗
drawDialog(canvas);
}
-
drawSideBar()方法:
此方法用于繪制索引欄的背景;
我們給索引欄的位置設(shè)置了一個枚舉類型有左側(cè)、右側(cè),因此這里最好的處理方式是將索引欄的上下左右位置保存到一個Rect對象中;
索引欄上下兩端的形狀也有一個枚舉類型分別為Normal、Round,因此在確定了索引欄的位置后,最好將索引欄的形狀封裝成一個Path對象,然后交由canvas繪制;
/**
* 計算SideBar的位置
*
* @return
*/
private Rect calculateSideBarPos() {
Rect rect = new Rect();
rect.top = mSideBarMarginTop;
rect.bottom = mHeight - mSideBarMarginBottom;
switch (mSideBarPosition) {
case LEFT:
rect.left = mSideBarSpacing;
rect.right = mSideBarSpacing + mSideBarWidth;
break;
case RIGHT:
rect.left = mWidth - mSideBarSpacing - mSideBarWidth;
rect.right = mWidth - mSideBarSpacing;
break;
default:
rect.left = mWidth - mSideBarSpacing - mSideBarWidth;
rect.right = mWidth - mSideBarSpacing;
break;
}
return rect;
}
/**
* 繪制索引欄的背景
*
* @param canvas
*/
private void drawSideBar(Canvas canvas) {
......(省略代碼)
// 計算索引欄的位置
Rect rect = calculateSideBarPos();
Path path = new Path();
if (mSideBarCap == SideBarCap.ROUND) {
// 圓形頭
......(省略代碼)
} else if (mSideBarCap == SideBarCap.NORMAL) {
// 方形頭
......(省略代碼)
}
......(省略代碼)
canvas.drawPath(path, mSideBarPaint);
}
-
drawSideBarLetter()方法:
此方法用于繪制索引欄上的字符,mItemHeight已經(jīng)計算出來了,也就意味著每個字符的中心位置已經(jīng)確定了,因此我們只要計算出畫筆的 baseLine 即可,關(guān)于如何計算Paint的 baseLine 值,可以參考我的文章Android 為控件增加數(shù)字提示,DrawText 方法解析;
/**
* 計算畫筆的基線
*
* @param paint
* @return
*/
private float calculatePaintBaseLine(Paint paint) {
Paint.FontMetrics metrics = paint.getFontMetrics();
return Math.abs(metrics.descent + metrics.ascent) / 2;
}
/**
* 繪制索引欄的字符
*
* @param canvas
*/
private void drawSideBarLetter(Canvas canvas) {
......(省略代碼)
// 獲取字符畫筆默認(rèn)的基線
float paintBaseLine = calculatePaintBaseLine(mSideBarLetterPaint);
// 字符的高度的起始位置
float startY = rect.top + mSideBarPaddingTop;
for (int i = 0; i < mLetters.length; i++) {
String letter = mLetters[i];
......(省略代碼)
// 繪制字符
canvas.drawText(letter, rect.left + mSideBarWidth / 2, startY + mItemHeight / 2 + paintBaseLine, mSideBarLetterPaint);
startY += mItemHeight;
}
}
-
drawDialog()方法:
此方法用于繪制彈窗,包含彈窗背景、彈窗的字符;
/**
* 繪制字符彈窗
*
* @param canvas
*/
private void drawDialog(Canvas canvas) {
......(省略代碼)
// 計算彈窗的半徑
float dialogRadius = ((mWidth - mSideBarWidth - mSideBarSpacing) * mDialogSizePercent) / 2;
// 默認(rèn)圓心
float cx = 0;
float cy = mHeight / 2;
// 計算索引欄不同位置時的彈窗水平偏移量
if (mSideBarPosition == SideBarPosition.LEFT) {
......(省略代碼)
} else if (mSideBarPosition == SideBarPosition.RIGHT) {
......(省略代碼)
}
if (!dialogIsFixed) {
// 彈窗位置不固定,隨著觸摸點的y值移動
......(省略代碼)
}
// 計算彈窗的邊框
......(省略代碼)
if (mDialogShape == DialogShape.SQUARE) { // 方形彈窗
Path path = new Path();
......(省略代碼)
canvas.drawPath(path, mDialogPaint);
} else if (mDialogShape == DialogShape.CIRCLE) { // 圓形彈窗
canvas.drawCircle(cx, cy, dialogRadius, mDialogPaint);
}
// 繪制彈窗字符
String letter = mLetters[mCurrPosition];
float baseLine = cy + calculatePaintBaseLine(mDialogLetterPaint);
canvas.drawText(letter, cx, baseLine, mDialogLetterPaint);
}
彈窗可以根據(jù)參數(shù)來設(shè)置橫向的位置,同時垂直方向可以設(shè)置跟隨手指移動(魅族文件夾索引)也可以固定在SideBar的中間位置(微信聯(lián)系人索引),同樣這里的字符繪制需要將畫筆的屬性設(shè)置成居中對齊,然后再根據(jù) baseLine就可以將字符繪制在彈窗的中心位置;
mDialogLetterPaint.setTextAlign(Paint.Align.CENTER);
-
步驟四:重寫 onTouchEvent()
根據(jù)前面的分析,SideBar 只消費 x 位于前面計算出來的 字符欄的Rect對象范圍內(nèi)的 down 事件,否則不處理;
@Override
public boolean onTouchEvent(MotionEvent event) {
// 獲取索引欄的范圍
Rect rect = calculateSideBarPos();
float x = -1;
float y = -1;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
x = event.getX();
y = event.getY();
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
// 不處理此次觸摸事件
return false;
}
break;
case MotionEvent.ACTION_MOVE:
x = event.getX();
y = event.getY();
break;
case MotionEvent.ACTION_UP:
x = -1;
y = -1;
break;
case MotionEvent.ACTION_CANCEL:
x = -1;
y = -1;
break;
}
if (x != -1 && y != -1) {
isPress = true;
int lastPos = mCurrPosition;
calculatePosition(y);
if (mCurrPosition != -1
&& lastPos != mCurrPosition) { // 這里記錄上次的position防止同一個字符多次傳出
if (onLetterUpdateListener != null) {
onLetterUpdateListener.onLetterUpdate(mLetters[mCurrPosition]);
}
}
} else { // 不處理此次觸摸事件
isPress = false;
mCurrPosition = -1;
mCurrY = -1;
}
invalidate();
// 默認(rèn)消費事件,此處不需要觸發(fā)點擊事件等操作,因此直接返回 true 即可
return true;
}
/**
* 計算觸摸的位置
*
* @param y
*/
private void calculatePosition(float y) {
mCurrPosition = -1;
// 字符欄繪制字符的起始/結(jié)束位置
float startY = mSideBarMarginTop + mSideBarPaddingTop;
float endY = mHeight - mSideBarMarginBottom - mSideBarPaddingBottom;
// 當(dāng)前的y的位置,用于彈窗位置的使用
mCurrY = y;
if (mCurrY <= startY) {
mCurrY = startY;
} else if (mCurrY >= endY) {
mCurrY = endY;
}
// 計算當(dāng)前 y 值對應(yīng)的 字符數(shù)組的位置
for (int i = 0; i < mLetters.length; i++) {
if (y >= startY && y < startY + mItemHeight) {
mCurrPosition = i;
break;
}
startY += mItemHeight;
}
// sidebar 范圍之外的y值,取sidebar兩端的字符
if (mCurrPosition == -1) {
if (y <= startY && mLetters.length > 0) {
mCurrPosition = 0;
} else if (y >= endY && mLetters.length > 0) {
mCurrPosition = mLetters.length - 1;
}
}
}
整個方法的邏輯其實很簡單,如果down事件的位置位于 Rect 范圍內(nèi),那就將接下來的時間序列交由 SideBar 消費,否則就將此次事件序列做任何處理(關(guān)于View的事件分發(fā)流程這里就不展開了,感興趣的可以網(wǎng)上找資料或者查看我的文章);
然后就是將觸摸事件拿到的 y 值進(jìn)行計算,判斷屬于哪一個字符高度范圍內(nèi);
-
步驟五:回調(diào)函數(shù),將字符更新的事件傳遞出去
在步驟四中計算好了字符位置后我們需要將我們的更新字符事件傳遞出去,通常我們會采用回調(diào)函數(shù)的方式將我們需要的數(shù)據(jù)傳遞出去,這里我們只需要將我們的當(dāng)前位置的 letter 傳遞出去即可;
/**
* 字符切換監(jiān)聽
*/
public interface OnLetterUpdateListener {
void onLetterUpdate(String letter);
}
// --------------------
// 觸摸事件中將方法傳遞出去
if (x != -1 && y != -1) {
isPress = true;
int lastPos = mCurrPosition;
calculatePosition(y);
if (mCurrPosition != -1
&& lastPos != mCurrPosition) { // 這里記錄上次的position防止同一個字符多次傳出
if (onLetterUpdateListener != null) {
onLetterUpdateListener.onLetterUpdate(mLetters[mCurrPosition]);
}
}
}
這里有一點比較關(guān)鍵的是我們需要在傳遞字符的時候記錄一下上一次的字符,如果和上一次不同才調(diào)用回調(diào)函數(shù)將數(shù)據(jù)傳遞出去,否則在同一個字符的 mItemHeight 高度內(nèi)滑動會一直觸發(fā)回調(diào)函數(shù);

總結(jié)
整個自定義控件的過程不是很難,主要是抽取出來的屬性有點多,所有的屬性按照三個模塊來劃分其實也是比較好理解的,看代碼學(xué)習(xí)不如擼代碼學(xué)習(xí),寫多了自然而然就會熟練,而且很多東西是共用的,比如計算Paint的baseLine,事件的分發(fā)機(jī)制等;
- 最后我們再來看下幾張效果演示


