天天看點

【Android 仿微信通訊錄 導航分組清單-下】自定義View為RecyclerView打造右側索引導航欄IndexBar

本篇文章已授權微信公衆号 guolin_blog (郭霖)獨家釋出

轉載請标明出處:

http://blog.csdn.net/zxt0601/article/details/52420706

本文出自:【張旭童的部落格】(http://blog.csdn.net/zxt0601)

代碼傳送門:喜歡的話,随手點個star。多謝

https://github.com/mcxtzhang/ItemDecorationIndexBar

一 概述

在上篇文章(http://blog.csdn.net/zxt0601/article/details/52355199)裡,我們用ItemDecoration為RecyclerView打造了帶懸停頭部的分組清單。其實Android版微信的通訊錄界面,它的分組title也不是懸停的,我們已經領先了微信一小步(認真臉)~

再看看市面上常見的分組清單(例如餓了麼點餐商品清單),不僅有懸停頭部,懸停頭部在切換時,還會伴有切換動畫。

關于ItemDecoration還有一個問題,簡單布局還好,我們可以draw出來,如果是複雜的頭部呢?能否寫個xml,inflate進來,這樣使用起來才簡單,即另一種簡單使用onDraw和onDrawOver的姿勢。

so,本文開頭我們就先用兩節完善一下我們的ItemDecoration。然後進入正題:自定義View實作右側索引導航欄IndexBar,對資料源的排序字段按照拼音排序,最後将RecyclerView和IndexBar關聯起來,觸摸IndexBar上相應字母,RecyclerView滾動到相應位置。(在螢幕中間顯示的其實就是一個TextView,我們set個體IndexBar即可)

由于大部分使用右側索引導航欄的場景,都需要這幾個固定步驟,對資料源排序,set給IndexBar,和RecyclerView關聯等,是以最後再将其封裝一把,成一個高度封裝,是以擴充性不太高的控件,更友善使用,如果需要擴充的話,反正看完本文再其基礎上修改應該很簡單~。

最終版預覽:

【Android 仿微信通訊錄 導航分組清單-下】自定義View為RecyclerView打造右側索引導航欄IndexBar

本文摘要:

1. 用ItemDecoration實作懸停頭部切換動畫

2. 另一種簡單使用onDraw()和onDrawOver()的姿勢

3. 自定義View實作右側**索引導航欄**IndexBar

4. 使用TinyPinyin對資料源排序

5. 關聯IndexBar和RecyclerView。

6. 封裝重複步驟,友善二次使用,并可定制導航資料源。

二 懸停頭部的“切換動畫”

實作了兩種,

第一種就是仿餓了麼點餐時,商品清單的懸停頭部切換“動畫效果”,如下:

【Android 仿微信通訊錄 導航分組清單-下】自定義View為RecyclerView打造右側索引導航欄IndexBar

第二種是一種頭部折疊起來的視效,個人覺得也還不錯~如下:(估計沒人喜歡)

【Android 仿微信通訊錄 導航分組清單-下】自定義View為RecyclerView打造右側索引導航欄IndexBar

果然比上部殘篇裡的效果好看多了,那麼代碼多不多呢,看我的git show 記錄:

【Android 仿微信通訊錄 導航分組清單-下】自定義View為RecyclerView打造右側索引導航欄IndexBar

就綠色部分的不到十行代碼就搞定~先上這個圖是為了讓大家安心,代碼不多,分分鐘看完。

下面放上文字版代碼,江湖人稱 注釋張 的我,已經寫滿了注釋,

再簡單說下吧,

滑動時,在判斷頭部即将切換(目前pos的tag和pos+1的tag不等)的時候,

1.計算出目前懸停頭部應該上移的位移,

利用Canvas的畫布移動方法Canvas.translate(),即可實作“餓了麼”懸停頭部切換效果。

2.計算出目前懸停頭部應該在螢幕上還剩餘的空間高度,作為頭部繪制的高度

利用Canvas的Canvas.clipRect()方法,剪切畫布,即可實作“折疊”的視效。

@Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {//最後調用 繪制在最上層
        int pos = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();

        String tag = mDatas.get(pos).getTag();
        //View child = parent.getChildAt(pos);
        View child = parent.findViewHolderForLayoutPosition(pos).itemView;//出現一個奇怪的bug,有時候child為空,是以将 child = parent.getChildAt(i)。-》 parent.findViewHolderForLayoutPosition(pos).itemView

        boolean flag = false;//定義一個flag,Canvas是否位移過的标志
        if ((pos + ) < mDatas.size()) {//防止數組越界(一般情況不會出現)
            if (null != tag && !tag.equals(mDatas.get(pos + ).getTag())) {//目前第一個可見的Item的tag,不等于其後一個item的tag,說明懸浮的View要切換了
                Log.d("zxt", "onDrawOver() called with: c = [" + child.getTop());//當getTop開始變負,它的絕對值,是第一個可見的Item移出螢幕的距離,
                if (child.getHeight() + child.getTop() < mTitleHeight) {//當第一個可見的item在螢幕中還剩的高度小于title區域的高度時,我們也該開始做懸浮Title的“交換動畫”
                    c.save();//每次繪制前 儲存目前Canvas狀态,
                    flag = true;

                    //一種頭部折疊起來的視效,個人覺得也還不錯~
                    //可與行 c.drawRect 比較,隻有bottom參數不一樣,由于 child.getHeight() + child.getTop() < mTitleHeight,是以繪制區域是在不斷的減小,有種折疊起來的感覺
                    //c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop());

                    //類似餓了麼點餐時,商品清單的懸停頭部切換“動畫效果”
                    //上滑時,将canvas上移 (y為負數) ,是以後面canvas 畫出來的Rect和Text都上移了,有種切換的“動畫”感覺
                    c.translate(, child.getHeight() + child.getTop() - mTitleHeight);
                }
            }
        }
        mPaint.setColor(COLOR_TITLE_BG);
        c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mPaint);
        mPaint.setColor(COLOR_TITLE_FONT);
        mPaint.getTextBounds(tag, , tag.length(), mBounds);
        c.drawText(tag, child.getPaddingLeft(),
                parent.getPaddingTop() + mTitleHeight - (mTitleHeight /  - mBounds.height() / ),
                mPaint);
        if (flag)
            c.restore();//恢複畫布到之前儲存的狀态


    }
           

這份代碼核心處

c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);

實作的是餓了麼效果,被注釋掉的

實作的是效果二。

三 另一種使用onDraw()和onDrawOver()的姿勢

之前我們使用onDraw(),onDrawOver(),都是用canvas的方法活生生的繪制一個出View,這對于很多人(包括我)來說都不容易,xy坐标的确認,尺寸都較難把握,基本上調UI效果時間都很長。尤其是canvas.drawText()方法的y坐标,其實是baseLine的位置,不了解的童鞋肯定要踩很多坑。

當我們想要繪制的分類title、懸停頭部複雜一點時,我都不敢想象要調試多久了,這個時候我們還敢用ItemDecoration嗎。

有沒有一種方法,就像我們平時使用的那樣,在Layout布局xml裡畫好View,然後inflate出來就可以了呢。

這個問題開始确實也把我難住了,難道又要從入門到放棄了嗎?

于是我又搜尋資料,功夫不負有心人。

解決問題的辦法就是,View類的:

public void draw(Canvas canvas) {

方法

下面我們就看一個用法Demo吧:

布局layout:header_complex.xml(注意有個ProgressBar哦)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/colorPrimaryDark"
    android:orientation="vertical">

    <Button
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent"
        android:text="複雜頭部" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="複雜頭部"
        android:textColor="@color/colorAccent" />

    <ProgressBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</LinearLayout>
           

onDrawOver代碼如下:簡單講解下,先inflate這個複雜的Layout,然後拿到它的LayoutParams,利用這個lp拿到寬和高的MeasureSpec,然後依次調用 measure,layout,draw方法,将複雜頭部顯示在螢幕上。

(小安利一下,MeasureSpec不太了解的可以看看我的這篇http://blog.csdn.net/zxt0601/article/details/52331007)

View toDrawView = mInflater.inflate(R.layout.header_complex, parent, false);
        int toDrawWidthSpec;//用于測量的widthMeasureSpec
        int toDrawHeightSpec;//用于測量的heightMeasureSpec
        //拿到複雜布局的LayoutParams,如果為空,就new一個。
        // 後面需要根據這個lp 建構toDrawWidthSpec,toDrawHeightSpec
        ViewGroup.LayoutParams lp = toDrawView.getLayoutParams();
        if (lp == null) {
            lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);//這裡是根據複雜布局layout的width height,new一個Lp
            toDrawView.setLayoutParams(lp);
        }
        if (lp.width == ViewGroup.LayoutParams.MATCH_PARENT) {
            //如果是MATCH_PARENT,則用父控件能配置設定的最大寬度和EXACTLY建構MeasureSpec。
            toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.EXACTLY);
        } else if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
            //如果是WRAP_CONTENT,則用父控件能配置設定的最大寬度和AT_MOST建構MeasureSpec。
            toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.AT_MOST);
        } else {
            //否則則是具體的寬度數值,則用這個寬度和EXACTLY建構MeasureSpec。
            toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY);
        }
        //高度同理
        if (lp.height == ViewGroup.LayoutParams.MATCH_PARENT) {
            toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.EXACTLY);
        } else if (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
            toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.AT_MOST);
        } else {
            toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY);
        }
        //依次調用 measure,layout,draw方法,将複雜頭部顯示在螢幕上。
        toDrawView.measure(toDrawWidthSpec, toDrawHeightSpec);
        toDrawView.layout(parent.getPaddingLeft(), parent.getPaddingTop(),
                parent.getPaddingLeft() + toDrawView.getMeasuredWidth(), parent.getPaddingTop() + toDrawView.getMeasuredHeight());
        toDrawView.draw(c);
           

這裡還有個有趣的地方,某些需要不斷調用onDraw()更新繪制自己最新狀态的View,例如ProgressBar,由于在螢幕上顯示的并不是真正的View,隻是我們手動的調用了一次draw方法,進而調用View的onDraw()顯示的一次“殘影”,是以ProgressBar隻會顯示onDraw()當時的樣子,并不會主動重新整理了。

看圖說話,還是很容易了解的:

【Android 仿微信通訊錄 導航分組清單-下】自定義View為RecyclerView打造右側索引導航欄IndexBar

滑動時,由于會回調onDrawOver() 方法,是以ProgressBar又被手動調用了draw(),開始變化,滑動的快的話,progressBar會有動畫效果。

停止不動時,ProgressBar也是靜止的,保持draw()時繪制的狀态。

四 自定義View實作右側索引導航欄IndexBar

不管是自定義ItemDecoration還是實作右側索引導航欄,都有大量的自定義View知識在裡面 ,這裡簡單複習一下。

(步驟1-4是自定義View的必須套路,步驟5+是IndexBar特殊定制)

1 自定義View首先要确定這個View需要在xml裡接受哪些屬性?

在IndexBar裡,我們先需要兩個屬性,每個索引的文字大小和手指按下時整個View的背景,

即在attrs.xml如下定義:

<attr name="textSize" format="dimension" />
    <declare-styleable name="IndexBar">
        <attr name="textSize" />
        <attr name="pressBackground" format="color" />
    </declare-styleable>
           

2 在View的構造方法中獲得我們自定義的屬性

套路代碼如下,都是套路,記得使用完最後要将typeArray對象 recycle()。

int textSize = (int) TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_SP, , getResources().getDisplayMetrics());//預設的TextSize
        mPressedBackground = Color.BLACK;//預設按下是純黑色
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, );
        int n = typedArray.getIndexCount();
        for (int i = ; i < n; i++) {
            int attr = typedArray.getIndex(i);
            switch (attr) {
                case R.styleable.IndexBar_textSize:
                    textSize = typedArray.getDimensionPixelSize(attr, textSize);
                    break;
                case R.styleable.IndexBar_pressBackground:
                    mPressedBackground = typedArray.getColor(attr, mPressedBackground);
                default:
                    break;
            }
        }
        typedArray.recycle();
           

3 重寫onMesure()方法(可選)

onMeasure()方法裡,主要就是周遊一遍indexDatas,得到index最大寬度和高度。然後根據三種測量模式,配置設定不同的值給View,

EXACLTY就配置設定具體的測量值(match_parent,确定數值),

AT_MOST就配置設定父控件能給的最大值和自己需要的值之間的最小值。(保證不超過父控件限定的值)

UNSPECIFIED則配置設定自己需要的值。(随心所欲)

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //取出寬高的MeasureSpec  Mode 和Size
        int wMode = MeasureSpec.getMode(widthMeasureSpec);
        int wSize = MeasureSpec.getSize(widthMeasureSpec);
        int hMode = MeasureSpec.getMode(heightMeasureSpec);
        int hSize = MeasureSpec.getSize(heightMeasureSpec);
        int measureWidth = , measureHeight = ;//最終測量出來的寬高

        //得到合适寬度:
        Rect indexBounds = new Rect();//存放每個繪制的index的Rect區域
        String index;//每個要繪制的index内容
        for (int i = ; i < mIndexDatas.size(); i++) {
            index = mIndexDatas.get(i);
            mPaint.getTextBounds(index, , index.length(), indexBounds);//測量計算文字所在矩形,可以得到寬高
            measureWidth = Math.max(indexBounds.width(), measureWidth);//循環結束後,得到index的最大寬度
            measureHeight = Math.max(indexBounds.width(), measureHeight);//循環結束後,得到index的最大高度,然後*size
        }
        measureHeight *= mIndexDatas.size();
        switch (wMode) {
            case MeasureSpec.EXACTLY:
                measureWidth = wSize;
                break;
            case MeasureSpec.AT_MOST:
                measureWidth = Math.min(measureWidth, wSize);//wSize此時是父控件能給子View配置設定的最大空間
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }

        //得到合适的高度:
        switch (hMode) {
            case MeasureSpec.EXACTLY:
                measureHeight = hSize;
                break;
            case MeasureSpec.AT_MOST:
                measureHeight = Math.min(measureHeight, hSize);//wSize此時是父控件能給子View配置設定的最大空間
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }

        setMeasuredDimension(measureWidth, measureHeight);
    }
           

4 重寫onDraw()方法

整理一下需求和思路:

利用index資料源的size,和控件可繪制的高度(高度-paddingTop-paddingBottom),求出每個index區域的高度mGapHeight。

每個index在繪制時,都是處于水準居中,豎直方向上在mGapHeight區域高度内居中。

思路整理清楚,代碼很簡單如下:

public static String[] INDEX_STRING = {"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 List<String> mIndexDatas;//索引資料源
    private int mGapHeight;//每個index區域的高度
    .....
    mIndexDatas = Arrays.asList(INDEX_STRING);//資料源
           

在onSizeChanged方法裡,擷取控件的寬高,并計算出mGapHeight:

@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = w;
        mHeight = h;
        mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size();
    }
           

最後在onDraw()方法裡繪制,

如果對于豎直居中baseLine的計算不太了解可以先放置,這塊的确挺繞人,後面應該會寫一篇 canvas.drawText()x y坐标計算的小短文.

可記住重點就是 Paint預設的TextAlign是Left,即x方向,左對齊,是以x坐标決定繪制文字的左邊界。

y坐标是繪制文字的baseLine位置。

@Override
    protected void onDraw(Canvas canvas) {
        int t = getPaddingTop();//top的基準點(支援padding)
        Rect indexBounds = new Rect();//存放每個繪制的index的Rect區域
        String index;//每個要繪制的index内容
        for (int i = ; i < mIndexDatas.size(); i++) {
            index = mIndexDatas.get(i);
            mPaint.getTextBounds(index, , index.length(), indexBounds);//測量計算文字所在矩形,可以得到寬高
            Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//獲得畫筆的FontMetrics,用來計算baseLine。因為drawText的y坐标,代表的是繪制的文字的baseLine的位置
            int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / );//計算出在每格index區域,豎直居中的baseLine值
            canvas.drawText(index, mWidth /  - indexBounds.width() / , t + mGapHeight * i + baseline, mPaint);//調用drawText,居中顯示繪制index
        }
    }
           

以上四步基本完成了IndexBar的繪制工作,下面我們為它添加一些行為的響應。

5 重寫onTouchEvent()方法

我們需要重寫onTouchEvent()方法,

以便處理手指按下時的View背景變色,擡起時恢複原來顔色

并根據手指觸摸的落點坐标,判斷目前處于哪個index區域,回調給相應的監聽器處理(顯示目前index的值,滑動RecyclerView至相應區域等。。)

代碼如下:

@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setBackgroundColor(mPressedBackground);//手指按下時背景變色
                //注意這裡沒有break,因為down時,也要計算落點 回調監聽器
            case MotionEvent.ACTION_MOVE:
                float y = event.getY();
                //通過計算判斷落點在哪個區域:
                int pressI = (int) ((y - getPaddingTop()) / mGapHeight);
                //邊界處理(在手指move時,有可能已經移出邊界,防止越界)
                if (pressI < ) {
                    pressI = ;
                } else if (pressI >= mIndexDatas.size()) {
                    pressI = mIndexDatas.size() - ;
                }
                //回調監聽器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI));
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            default:
                setBackgroundResource(android.R.color.transparent);//手指擡起時背景恢複透明
                //回調監聽器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onMotionEventEnd();
                }
                break;
        }
        return true;
    }
           

6 關聯IndexBar和RecyclerView

具體的操作交由監聽器處理,定義和實作如下:

值得一提的就是,滑動RecyclerView到指定postion,我們使用的是LinearLayoutManager的

scrollToPositionWithOffset(int position, int offset)

方法,offset傳入0,postion即目标postion即可。如果使用

RecyclerView.scrollToPosition();

等方法,滑動會很飄~定位不準。

mPressedShowTextView 就是在螢幕中間顯示的目前處于哪個index的TextView。

/**
     * 目前被按下的index的監聽器
     */
    public interface onIndexPressedListener {
        void onIndexPressed(int index, String text);//當某個Index被按下

        void onMotionEventEnd();//當觸摸事件結束(UP CANCEL)
    }

    private onIndexPressedListener mOnIndexPressedListener;

    public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) {
        this.mOnIndexPressedListener = mOnIndexPressedListener;
    }
           
//設定index觸摸監聽器
        setmOnIndexPressedListener(new onIndexPressedListener() {
            @Override
            public void onIndexPressed(int index, String text) {
                if (mPressedShowTextView != null) { //顯示hintTexView
                    mPressedShowTextView.setVisibility(View.VISIBLE);
                    mPressedShowTextView.setText(text);
                }
                //滑動Rv
                if (mLayoutManager != null) {
                    int position = getPosByTag(text);
                    if (position != -) {
                        mLayoutManager.scrollToPositionWithOffset(position, );
                    }
                }
            }

            @Override
            public void onMotionEventEnd() {
                //隐藏hintTextView
                if (mPressedShowTextView != null) {
                    mPressedShowTextView.setVisibility(View.GONE);
                }
            }
        });
           

五 封裝重複步驟,友善二次使用。

在我個人的了解裡,程式過多的封裝是會導緻擴充性的降低(也是因為我水準有限),然而我們今天要封裝的這個IndexBar,由于使用場景和套路還是挺固定的(城市分組清單,商品分類清單)是以值得将相關的操作都聚合起來,二次使用更友善。畢竟,一個項目裡同樣的代碼寫第二遍的程式員都不是好的聖鬥士。(其實是我的leader不想寫第二遍,讓我封裝一下給他秒用)

梳理一下固定的操作:

1 都是先對原始資料sourceDatas源按照排序字段拼音排序。

2 然後将螢幕中hint的TextView ,以及索引資料源indexDatas(通過sourceDatas獲得),通過set方法傳給IndexBar。

3 關聯IndexBar和RecyclerView,使得觸摸IndexBar相應區域RecyclerView會滾動(借助sourceDatas獲得對應postion)。

根據上述,我的設想在使用時,隻需要給IndexBar設定 原始資料sourceDatas,HintTextView,和RecyclerView的LinearLayoutManager,在IndexBar内部對sourceDatas排序,并獲得索引資料源indexDatas,然後設定一個預設的index觸摸監聽器,在手指按下滑動時,由于IndexBar持有HintTextView和LayoutManager,則HintTextView的show hide,以及LayoutManager的滾動 都在IndexBar内部完成。

最終使用預覽:

//使用indexBar
        mTvSideBarHint = (TextView) findViewById(R.id.tvSideBarHint);//HintTextView
        mIndexBar = (IndexBar) findViewById(R.id.indexBar);//IndexBar
        mIndexBar.setmPressedShowTextView(mTvSideBarHint)//設定HintTextView
                .setNeedRealIndex(true)//設定需要真實的索引
                .setmLayoutManager(mManager)//設定RecyclerView的LayoutManager
                .setmSourceDatas(mDatas);//設定資料源
           

布局xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    </android.support.v7.widget.RecyclerView>

    <mcxtzhang.itemdecorationdemo.IndexBar.widget.IndexBar
        android:id="@+id/indexBar"
        android:layout_width="24dp"
        android:layout_height="match_parent"
        android:layout_gravity="right"
        app:pressBackground="@color/partTranslucent"
        app:textSize="16sp" />

    <TextView
        android:id="@+id/tvSideBarHint"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_gravity="center"
        android:background="@drawable/shape_side_bar_bg"
        android:gravity="center"
        android:textColor="@android:color/white"
        android:textSize="48sp"
        android:visibility="gone"
        tools:text="A"
        tools:visibility="visible" />

</FrameLayout>
           

其中,setNeedRealIndex(true)//設定需要真實的索引,是指索引欄的資料不是固定的A-Z,#。而是根據真實的sourceDatas生成。

因為鍊式調用用起來很爽,是以在這些set方法裡都return 了 this。

1 抽象兩個實體類和一個接口。

先把tag抽象出來,放在頂層,這裡存放的就是IndexBar顯示的每個index值(A-Z,#)(本例是城市的漢語拼音首字母),而且在關聯滑動時,根據tag擷取postion時,也需要用到tag。它是導航分組清單的基礎。

public class BaseIndexTagBean {
    private String tag;//所屬的分類(城市的漢語拼音首字母)

    public String getTag() {
        return tag;
    }

    public void setTag(String tag) {
        this.tag = tag;
    }
}
           

然後抽象一個接口和一個實體類,

接口定義一個方法getTarget(),它傳回 需要被轉化成拼音,并取出首字母 索引排序的 字段。(本例就是城市的名字)

實體類繼承BaseIndexTagBean,并實作以上接口,且額外存放 需要排序的字段的拼音值,(本例是城市的拼音)。它根據getTarget()傳回的值利用TinyPinyin庫得到拼音。

public interface IIndexTargetInterface {
    String getTarget();//需要被轉化成拼音,并取出首字母 索引排序的 字段
}
           
public abstract class BaseIndexPinyinBean extends BaseIndexTagBean implements IIndexTargetInterface {
    private String pyCity;//城市的拼音

    public String getPyCity() {
        return pyCity;
    }

    public void setPyCity(String pyCity) {
        this.pyCity = pyCity;
    }
}
           

有了以上兩個類一個接口,我們就可以将 對原始資料源sourceDatas按照拼音排序,并取出索引資料源indexDatas的操作封裝起來。

2 封裝原始資料源初始化(利用TinyPinyin擷取全拼音),取出索引資料源indexDatas的操作。

使用時,我們先讓具體的實體bean,繼承自BaseIndexPinyinBean ,在getTarget()方法傳回排序目标字段。本例如下:

public class CityBean extends BaseIndexPinyinBean {

    private String city;//城市名字

    public CityBean() {
    }
    public CityBean(String city) {
        this.city = city;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    @Override
    public String getTarget() {
        return city;
    }
}
           

IndexBar類内代碼:

使用時會調用IndexBar.setmSourceDatas()方法傳入原始資料源,在方法内對資料源初始化,并取出索引資料源。

private List<? extends BaseIndexPinyinBean> mSourceDatas;//Adapter的資料源
    public IndexBar setmSourceDatas(List<? extends BaseIndexPinyinBean> mSourceDatas) {
        this.mSourceDatas = mSourceDatas;
        initSourceDatas();//對資料源進行初始化
        return this;
    }
           
/**
     * 初始化原始資料源,并取出索引資料源
     *
     * @return
     */
    private void initSourceDatas() {
        int size = mSourceDatas.size();
        for (int i = ; i < size; i++) {
            BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i);
            StringBuilder pySb = new StringBuilder();
            String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段
            //周遊target的每個char得到它的全拼音
            for (int i1 = ; i1 < target.length(); i1++) {
                //利用TinyPinyin将char轉成拼音
                //檢視源碼,方法内 如果char為漢字,則傳回大寫拼音
                //如果c不是漢字,則傳回String.valueOf(c)
                pySb.append(Pinyin.toPinyin(target.charAt(i1)));
            }
            indexPinyinBean.setPyCity(pySb.toString());//設定城市名全拼音

            //以下代碼設定城市拼音首字母
            String tagString = pySb.toString().substring(, );
            if (tagString.matches("[A-Z]")) {//如果是A-Z字母開頭
                indexPinyinBean.setTag(tagString);
                if (isNeedRealIndex) {//如果需要真實的索引資料源
                    if (!mIndexDatas.contains(tagString)) {//則判斷是否已經将這個索引添加進去,若沒有則添加
                        mIndexDatas.add(tagString);
                    }
                }
            } else {//特殊字母這裡統一用#處理
                indexPinyinBean.setTag("#");
                if (isNeedRealIndex) {//如果需要真實的索引資料源
                    if (!mIndexDatas.contains("#")) {
                        mIndexDatas.add("#");
                    }
                }
            }
        }
        sortData();
    }
           

3 封裝對原始資料源sourceDatas,索引資料源indexDatas的排序操作。

/**
     * 對資料源排序
     */
    private void sortData() {
        //對右側欄進行排序 将 # 丢在最後
        Collections.sort(mIndexDatas, new Comparator<String>() {
            @Override
            public int compare(String lhs, String rhs) {
                if (lhs.equals("#")) {
                    return ;
                } else if (rhs.equals("#")) {
                    return -;
                } else {
                    return lhs.compareTo(rhs);
                }
            }
        });

        //對資料源進行排序
        Collections.sort(mSourceDatas, new Comparator<BaseIndexPinyinBean>() {
            @Override
            public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) {
                if (lhs.getTag().equals("#")) {
                    return ;
                } else if (rhs.getTag().equals("#")) {
                    return -;
                } else {
                    return lhs.getPyCity().compareTo(rhs.getPyCity());
                }
            }
        });
    }
           

4 是否需要真實的索引資料源。

相關變量定義:

public static String[] INDEX_STRING = {"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 List<String> mIndexDatas;//索引資料源
    private boolean isNeedRealIndex;//是否需要根據實際的資料來生成索引資料源(例如 隻有 A B C 三種tag,那麼索引欄就 A B C 三項)
           

初始化init時,判斷不需要真實的索引資料源,就用預設值(A-Z,#)

if (!isNeedRealIndex) {//不需要真實的索引資料源
            mIndexDatas = Arrays.asList(INDEX_STRING);
        }
           

使用時,如果如果真實索引資料源,調用這個方法,傳入true,一定要在設定資料源setmSourceDatas(List)之前調用。

/**
     * 一定要在設定資料源{@link #setmSourceDatas(List)}之前調用
     *
     * @param needRealIndex
     * @return
     */
    public IndexBar setNeedRealIndex(boolean needRealIndex) {
        isNeedRealIndex = needRealIndex;
        if (isNeedRealIndex){
            if (mIndexDatas != null) {
                mIndexDatas = new ArrayList<>();
            }
        }
        return this;
    }
           

在initSourceDatas() 裡,會根據這個變量往mIndexDatas裡增加index。

5 IndexBar和外部關聯的相關(HintTextView,和RecyclerView的LinearLayoutManager)

set方法很簡單:

public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) {
        this.mPressedShowTextView = mPressedShowTextView;
        return this;
    }

    public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) {
        this.mLayoutManager = mLayoutManager;
        return this;
    }
           

它們兩最終都是在index觸摸監聽器裡用到,代碼上文已提及,隻不過這次挪到IndexBar内部init裡。

init函數如下:

private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        ...
        if (!isNeedRealIndex) {//不需要真實的索引資料源
            mIndexDatas = Arrays.asList(INDEX_STRING);
        }
        //設定index觸摸監聽器
        setmOnIndexPressedListener(new onIndexPressedListener() {
            @Override
            public void onIndexPressed(int index, String text) {
                if (mPressedShowTextView != null) { //顯示hintTexView
                    mPressedShowTextView.setVisibility(View.VISIBLE);
                    mPressedShowTextView.setText(text);
                }
                //滑動Rv
                if (mLayoutManager != null) {
                    int position = getPosByTag(text);
                    if (position != -) {
                        mLayoutManager.scrollToPositionWithOffset(position, );
                    }
                }
            }
            @Override
            public void onMotionEventEnd() {
                //隐藏hintTextView
                if (mPressedShowTextView != null) {
                    mPressedShowTextView.setVisibility(View.GONE);
                }
            }
        });
    }
           
/**
     * 根據傳入的pos傳回tag
     *
     * @param tag
     * @return
     */
    private int getPosByTag(String tag) {
        if (TextUtils.isEmpty(tag)) {
            return -;
        }
        for (int i = ; i < mSourceDatas.size(); i++) {
            if (tag.equals(mSourceDatas.get(i).getTag())) {
                return i;
            }
        }
        return -;
    }
           

六 完整代碼

思前想後還是放出來吧,三百多行有點長

/**
 * 介紹:索引右側邊欄
 * 作者:zhangxutong
 * 郵箱:[email protected]
 * CSDN:http://blog.csdn.net/zxt0601
 * 時間: 16/09/04.
 */

public class IndexBar extends View {
    private static final String TAG = "zxt/IndexBar";
    public static String[] INDEX_STRING = {"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 List<String> mIndexDatas;//索引資料源
    private boolean isNeedRealIndex;//是否需要根據實際的資料來生成索引資料源(例如 隻有 A B C 三種tag,那麼索引欄就 A B C 三項)

    private int mWidth, mHeight;//View的寬高
    private int mGapHeight;//每個index區域的高度

    private Paint mPaint;

    private int mPressedBackground;//手指按下時的背景色

    //以下邊變量是外部set進來的
    private TextView mPressedShowTextView;//用于特寫顯示正在被觸摸的index值
    private List<? extends BaseIndexPinyinBean> mSourceDatas;//Adapter的資料源
    private LinearLayoutManager mLayoutManager;

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

    public IndexBar(Context context, AttributeSet attrs) {
        this(context, attrs, );
    }

    public IndexBar(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs, defStyleAttr);
    }

    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        int textSize = (int) TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_SP, , getResources().getDisplayMetrics());//預設的TextSize
        mPressedBackground = Color.BLACK;//預設按下是純黑色
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, );
        int n = typedArray.getIndexCount();
        for (int i = ; i < n; i++) {
            int attr = typedArray.getIndex(i);
            switch (attr) {
                case R.styleable.IndexBar_textSize:
                    textSize = typedArray.getDimensionPixelSize(attr, textSize);
                    break;
                case R.styleable.IndexBar_pressBackground:
                    mPressedBackground = typedArray.getColor(attr, mPressedBackground);
                default:
                    break;
            }
        }
        typedArray.recycle();

        if (!isNeedRealIndex) {//不需要真實的索引資料源
            mIndexDatas = Arrays.asList(INDEX_STRING);
        }
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setTextSize(textSize);
        mPaint.setColor(Color.BLACK);

        //設定index觸摸監聽器
        setmOnIndexPressedListener(new onIndexPressedListener() {
            @Override
            public void onIndexPressed(int index, String text) {
                if (mPressedShowTextView != null) { //顯示hintTexView
                    mPressedShowTextView.setVisibility(View.VISIBLE);
                    mPressedShowTextView.setText(text);
                }
                //滑動Rv
                if (mLayoutManager != null) {
                    int position = getPosByTag(text);
                    if (position != -) {
                        mLayoutManager.scrollToPositionWithOffset(position, );
                    }
                }
            }

            @Override
            public void onMotionEventEnd() {
                //隐藏hintTextView
                if (mPressedShowTextView != null) {
                    mPressedShowTextView.setVisibility(View.GONE);
                }
            }
        });
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        int t = getPaddingTop();//top的基準點(支援padding)
        Rect indexBounds = new Rect();//存放每個繪制的index的Rect區域
        String index;//每個要繪制的index内容
        for (int i = ; i < mIndexDatas.size(); i++) {
            index = mIndexDatas.get(i);
            mPaint.getTextBounds(index, , index.length(), indexBounds);//測量計算文字所在矩形,可以得到寬高
            Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//獲得畫筆的FontMetrics,用來計算baseLine。因為drawText的y坐标,代表的是繪制的文字的baseLine的位置
            int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / );//計算出在每格index區域,豎直居中的baseLine值
            canvas.drawText(index, mWidth /  - indexBounds.width() / , t + mGapHeight * i + baseline, mPaint);//調用drawText,居中顯示繪制index
        }
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setBackgroundColor(mPressedBackground);//手指按下時背景變色
                //注意這裡沒有break,因為down時,也要計算落點 回調監聽器
            case MotionEvent.ACTION_MOVE:
                float y = event.getY();
                //通過計算判斷落點在哪個區域:
                int pressI = (int) ((y - getPaddingTop()) / mGapHeight);
                //邊界處理(在手指move時,有可能已經移出邊界,防止越界)
                if (pressI < ) {
                    pressI = ;
                } else if (pressI >= mIndexDatas.size()) {
                    pressI = mIndexDatas.size() - ;
                }
                //回調監聽器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI));
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            default:
                setBackgroundResource(android.R.color.transparent);//手指擡起時背景恢複透明
                //回調監聽器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onMotionEventEnd();
                }
                break;
        }
        return true;
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = w;
        mHeight = h;
        mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size();
    }


    /**
     * 目前被按下的index的監聽器
     */
    public interface onIndexPressedListener {
        void onIndexPressed(int index, String text);//當某個Index被按下

        void onMotionEventEnd();//當觸摸事件結束(UP CANCEL)
    }

    private onIndexPressedListener mOnIndexPressedListener;

    public onIndexPressedListener getmOnIndexPressedListener() {
        return mOnIndexPressedListener;
    }

    public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) {
        this.mOnIndexPressedListener = mOnIndexPressedListener;
    }

    /**
     * 顯示目前被按下的index的TextView
     *
     * @return
     */

    public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) {
        this.mPressedShowTextView = mPressedShowTextView;
        return this;
    }

    public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) {
        this.mLayoutManager = mLayoutManager;
        return this;
    }

    /**
     * 一定要在設定資料源{@link #setmSourceDatas(List)}之前調用
     *
     * @param needRealIndex
     * @return
     */
    public IndexBar setNeedRealIndex(boolean needRealIndex) {
        isNeedRealIndex = needRealIndex;
        if (mIndexDatas != null) {
            mIndexDatas = new ArrayList<>();
        }
        return this;
    }

    public IndexBar setmSourceDatas(List<? extends BaseIndexPinyinBean> mSourceDatas) {
        this.mSourceDatas = mSourceDatas;
        initSourceDatas();//對資料源進行初始化
        return this;
    }


    /**
     * 初始化原始資料源,并取出索引資料源
     *
     * @return
     */
    private void initSourceDatas() {
        int size = mSourceDatas.size();
        for (int i = ; i < size; i++) {
            BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i);
            StringBuilder pySb = new StringBuilder();
            String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段
            //周遊target的每個char得到它的全拼音
            for (int i1 = ; i1 < target.length(); i1++) {
                //利用TinyPinyin将char轉成拼音
                //檢視源碼,方法内 如果char為漢字,則傳回大寫拼音
                //如果c不是漢字,則傳回String.valueOf(c)
                pySb.append(Pinyin.toPinyin(target.charAt(i1)));
            }
            indexPinyinBean.setPyCity(pySb.toString());//設定城市名全拼音

            //以下代碼設定城市拼音首字母
            String tagString = pySb.toString().substring(, );
            if (tagString.matches("[A-Z]")) {//如果是A-Z字母開頭
                indexPinyinBean.setTag(tagString);
                if (isNeedRealIndex) {//如果需要真實的索引資料源
                    if (!mIndexDatas.contains(tagString)) {//則判斷是否已經将這個索引添加進去,若沒有則添加
                        mIndexDatas.add(tagString);
                    }
                }
            } else {//特殊字母這裡統一用#處理
                indexPinyinBean.setTag("#");
                if (isNeedRealIndex) {//如果需要真實的索引資料源
                    if (!mIndexDatas.contains("#")) {
                        mIndexDatas.add("#");
                    }
                }
            }
        }
        sortData();
    }

    /**
     * 對資料源排序
     */
    private void sortData() {
        //對右側欄進行排序 将 # 丢在最後
        Collections.sort(mIndexDatas, new Comparator<String>() {
            @Override
            public int compare(String lhs, String rhs) {
                if (lhs.equals("#")) {
                    return ;
                } else if (rhs.equals("#")) {
                    return -;
                } else {
                    return lhs.compareTo(rhs);
                }
            }
        });

        //對資料源進行排序
        Collections.sort(mSourceDatas, new Comparator<BaseIndexPinyinBean>() {
            @Override
            public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) {
                if (lhs.getTag().equals("#")) {
                    return ;
                } else if (rhs.getTag().equals("#")) {
                    return -;
                } else {
                    return lhs.getPyCity().compareTo(rhs.getPyCity());
                }
            }
        });
    }


    /**
     * 根據傳入的pos傳回tag
     *
     * @param tag
     * @return
     */
    private int getPosByTag(String tag) {
        if (TextUtils.isEmpty(tag)) {
            return -;
        }
        for (int i = ; i < mSourceDatas.size(); i++) {
            if (tag.equals(mSourceDatas.get(i).getTag())) {
                return i;
            }
        }
        return -;
    }
}
           

七 總結

不管是自定義ItemDecoration還是實作右側索引導航欄,其實大量的自定義View知識在裡面 ,

so 要想自定義ItemDecoration玩得好,自定義View少不了。

對資料源的排序字段按照拼音排序,我們使用TinyPinyin(https://github.com/promeG/TinyPinyin)幫助我們排序。

它的特性很适合Android平台。

1. 生成的拼音不包含聲調,也不處理多音字,預設一個漢字對應一個拼音;

2. 拼音均為大寫;

3. 無需初始化,執行效率很高(Pinyin4J的4倍);

4. 很低的記憶體占用(小于30KB)。

(介紹來源于其項目github)

其實不僅僅是IndexBar以及它和RecyclerView,HintTextView的關聯可以封裝在一起。

懸停頭部ItemDecoration也可以利用 BaseIndexTagBean 類來抽象一下,不與具體的實體類耦合,

private List<CityBean> mDatas;
           

替換成

private List<?extends BaseIndexPinyinBean> mDatas;
           

即可。

本文起筆于9.5晚八點,項目上線打包期間,每逢打包是非多~你們懂得,結果打包期間出現各種問題,各種bug緊急修複通宵到淩晨,9.6日,兩點起床,又續寫後面三節。本文篇幅也略長,寫到後面自己也有點懵逼(也可能是通宵還沒醒導緻),總耗時6小時+,希望大家看後覺得有用可以給我刷波66666.

八 代碼位址

我不想吐槽CSDN了,上傳代碼真心慢,

稍後補上CSDN位址。

csdn傳送門:

http://download.csdn.net/detail/zxt0601/9623621

github位址:歡迎star

https://github.com/mcxtzhang/ItemDecorationIndexBar