Android - RecyclerView進階(2)—ItemDecoration與城市列表

我的CSDN: ListerCi
我的簡書: 東方未曦

寫在前面

本系列博客的demo都上傳到了github:RecyclerViewDemo
如果有幫助到你的話不妨給我點個star~

在介紹ItemDecoration之前我們不妨先看下它能實現什麼功能。
這是一個國內大部分城市的列表,通過城市拼音對其排序,通過拼音首字母對其分組。在滑動到某一組的城市時,它的Header會在頂部保持不動,下一組滑動上來時,新的Header會把上一組“頂”上去,這個效果就是ItemDecoration實現的。當然,爲了功能的完整性,我還添加了側邊欄用於搜索查找。

gif-城市列表demo.gif

看完效果後是不是對ItemDecoration充滿了興趣?現在讓我們一步一步去認識它並實現這個功能吧。

一、ItemDecoration簡介

1.1 API介紹

ItemDecoration是定義在RecyclerView內部的抽象類,排除過時的方法,它提供了3個可重寫的方法,代碼如下。

public abstract static class ItemDecoration {
        /**
         * 繪製提供給RecyclerView的裝飾
         * 任何由此方法繪製的內容都會在item繪製之前就繪製完畢,因此它是處於item下層的
         */
        public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
            onDraw(c, parent);
        }

        /**
         * 此方法與onDraw相對,方法中的內容是在item繪製完畢後開始繪製的
         * 因此會顯示在item上層
         */
        public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
                @NonNull State state) {
            onDrawOver(c, parent);
        }

        /**
         * 通過outRect表示當前item距離left、top、right、bottom 4個方向的距離
         */
        public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
                @NonNull RecyclerView parent, @NonNull State state) {
            getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                    parent);
        }
    }

其中onDraw(...)onDrawOver(...)方法的參數中傳入了畫布Canvas,通過畫布我們可以在任何座標繪製任何事物。
getItemOffsets(...)方法用於指定每個item距離左上右下4個方向的距離,效果同margin。參數中傳入的view表示當前的item,如果你想根據item的數據設置不同margin的話,可以通過RecyclerView的getChildAdapterPosition(View)得到該item在Adapter中的position。

1.2 DividerItemDecoration分析

ItemDecoration最簡單的用法就是添加分隔線,如果使用DividerItemDecoration,之後你會發現item之間多了一根細細的分隔線。

RecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));

那麼這個細線是怎麼畫出來的呢?回憶一下ItemDecoration中方法的作用,步驟應該是這樣的:首先我們通過getItemOffsets()在item之間添加間隔,然後通過onDraw()或者onDrawOver()在這段間隔內繪製線段。

爲了驗證我們的想法,來看一下DividerItemDecoration的源碼。我們使用的時候是垂直方向,代碼中水平方向的代碼我就省略掉了。

public class DividerItemDecoration extends RecyclerView.ItemDecoration {
    
    private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };
    private Drawable mDivider;
    // 垂直或水平方向
    private int mOrientation;

    private final Rect mBounds = new Rect();

    public DividerItemDecoration(Context context, int orientation) {
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0); // 默認的分隔線
        a.recycle();
        setOrientation(orientation);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (mOrientation == VERTICAL) {
            drawVertical(c, parent);
        }
    }

    private void drawVertical(Canvas canvas, RecyclerView parent) {
        canvas.save();
        final int left;
        final int right;
        // 根據RecyclerView是否有padding獲取線段的left和right的座標
        if (parent.getClipToPadding()) {
            left = parent.getPaddingLeft();
            right = parent.getWidth() - parent.getPaddingRight();
            canvas.clipRect(left, parent.getPaddingTop(), right,
                    parent.getHeight() - parent.getPaddingBottom());
        } else {
            left = 0;
            right = parent.getWidth();
        }
        // 得到當前顯示的每個child的線段的top和bottom座標
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            parent.getDecoratedBoundsWithMargins(child, mBounds);
            final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
            final int top = bottom - mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(canvas);
        }
        canvas.restore();
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
            RecyclerView.State state) {
        if (mDivider == null) {
            outRect.set(0, 0, 0, 0);
            return;
        }
        if (mOrientation == VERTICAL) {
            // 設置每個item與下方的間隔爲mDivider.getIntrinsicHeight()
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }
}

DividerItemDecoration的實現邏輯果然與我們想的一樣,先在getItemOffsets()中設置每個item與下方的間隔,隨後在onDraw(...)中調用drawVertical(Canvas canvas, RecyclerView parent)得到線段的邊界座標並繪製。

二、城市列表實現

2.1 分析實現方式

雖然demo中的效果是下一組的Header將上一組的Header頂了上去,但實現的邏輯並非如此,如果把Header的背景色調整爲半透明,效果是這樣的。

gif-半透明Header.gif

Header半透明之後,它就露出了馬腳,仔細觀察後我們可以總結實現這個效果所需的步驟:
① 每個分組的第一個城市item的上方都有一個Header,例如“阿壩”和“白城”的上方都有一個Header
② 當前RecyclerView所展示的第一個item的分類會顯示在RecyclerView的最上方,例如當前RecyclerView第一個item是“阿克蘇”、“安慶”等城市時,RecyclerView最上方會漂浮一個"A"類Header
③ 當某個分組的最後一個item滑出RecyclerView時,Header會隨着這個item一起滑走,這也是“頂上去”效果的由來。例如當“澳門”即將滑出RecyclerView時,"A"類Header會隨着“澳門”item一起滑走,並且我們很容易得到他們座標之間的關係:item.bottom = Header.bottom

2.2 具體實現

分析完步驟,即可開始實現這個效果。項目中的城市數據保存在arrays文件中,數據格式如下所示,我已經先爲每個城市添加了拼音併爲所有城市進行了排序,數據中還包括城市ID,完整數據請去博客開頭的github下載。

<string-array name="city">
        <item>阿壩</item>
        <item>aba</item>
        <item>101271901</item>
        <item>阿克蘇</item>
        <item>akesu</item>
        <item>101130801</item>
        <item>阿勒泰</item>
        <item>aletai</item>
        <item>101131401</item>
        <item>阿里</item>
        <item>ali</item>
        <item>101140701</item>
        <item>安康</item>
        <item>ankang</item>
        <item>101110701</item>
        <item>安慶</item>
        <item>anqing</item>
        <item>101220601</item>
        <item>鞍山</item>
        <item>anshan</item>
        <item>101070301</item>
        ......
</string-array>

在繪製Header時,我們需要知道一個item是不是它分組的第一個或者是最後一個,那麼需要構建這樣的一個實體類:

public class CityInfo {

    private String mCityName;
    private String mPinYin;
    private String mGroup;
    private String mCityID;
    private boolean mIsFirstInGroup;
    private boolean mIsLastInGroup;

    public CityInfo(String cityName, String pinYin, String cityID,
                    boolean isFirstInGroup, boolean isLastInGroup) {
        this.mCityName = cityName;
        this.mPinYin = pinYin;
        this.mGroup = mPinYin.substring(0, 1).toUpperCase();
        this.mCityID = cityID;
        this.mIsFirstInGroup = isFirstInGroup;
        this.mIsLastInGroup = isLastInGroup;
    }

    // setters and getters...
}

再來將數據都解析成實體類。由於數據是已經排好序的,那麼判斷一個item是不是它分組的第一個或最後一個可以用這種方式:如果第i個數據與第i-1個的group不相同,那麼i就是i的分組的第一個item;而i-1就是i-1的分組的最後一個item。

    private void prepareCityInfo() {
        mCityInfoList = new ArrayList<>();
        String[] cityArray = getResources().getStringArray(R.array.city);
        String curGroup = "0";
        // 每 3 個String構成一個CityInfo
        for (int i = 0; i < cityArray.length; i += 3) {
            CityInfo cityInfo = new CityInfo(cityArray[i], cityArray[i + 1], cityArray[i + 2],
                    false, false);
            if (!cityInfo.getGroup().equals(curGroup)) {
                // 如果當前城市的 group 信息與保存的不一致, 那麼就是該 group 的第一個
                cityInfo.setIsFirstInGroup(true);
                // 同時將該 group 信息添加到索引中
                indexList.add(cityInfo.getGroup());
                // 它的上一個城市就是上一個 group 的最後一個
                if (i > 0) {
                    mCityInfoList.get(mCityInfoList.size() - 1).setIsLastInGroup(true);
                }
                curGroup = cityInfo.getGroup();
            }
            mCityInfoList.add(cityInfo);
        }
    }

下面重點來看下怎麼自定義ItemDecoration,我們根據之前總結的步驟來,首先爲每組的第一個item繪製Header,在繪製Header之前需要通過getItemOffsets()方法爲Header預留空間。

    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
                               @NonNull RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        int position = parent.getChildAdapterPosition(view);
        CityInfo cityInfo = mCityInfoList.get(position);
        if (cityInfo.isFirstInGroup()) {
            outRect.top = GROUP_ITEM_TOP;
        }
    }

再通過onDraw()繪製,這裏parent.getChildCount()獲取到的是RecyclerView中當前可見的所有item的數量。

    @Override
    public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDraw(c, parent, state);
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View view = parent.getChildAt(i);
            int position = parent.getChildAdapterPosition(view);
            CityInfo cityInfo = mCityInfoList.get(position);
            if (cityInfo.isFirstInGroup()) {
                int left = parent.getPaddingLeft();
                int top = view.getTop() - GROUP_ITEM_TOP;
                int right = parent.getRight() - parent.getPaddingRight();
                int bottom = view.getTop();
                // 繪製背景
                c.drawRect(left, top, right, bottom, mBackGroundPaint);
                // 繪製文字
                drawText(c, left, top, bottom, cityInfo.getGroup());
            }
        }
    }

再來繪製固定於RecyclerView頂端的Header,這裏只要得到RecyclerView所展示的第一個item的group並將其繪製即可。由於這個Header是在RecyclerView上層的,因此需要在onDrawOver()中繪製。

public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        // 得到當前所展示的第一個View
        View view = parent.getChildAt(0);
        int position = parent.getChildAdapterPosition(view);
        CityInfo cityInfo = mCityInfoList.get(position);
            
        int left = parent.getPaddingLeft();
        int top = parent.getPaddingTop();
        int right = parent.getRight() - parent.getPaddingRight();
        int bottom = top + GROUP_ITEM_TOP;
        c.drawRect(left, top, right, bottom, mBackGroundPaint);

        drawText(c, left, top, bottom, cityInfo.getGroup());
    }

最後需要實現就是Header隨着當前group的最後一個item移動的效果,移動時Header的bottom與item的bottom一致即可。那什麼時候開始移動呢?很顯然就是當這個item的bottom與RecyclerView頂部的距離小於Header的高度時開始移動。我們修改onDrawOver()方法如下即可。

    @Override
    public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        View view = parent.getChildAt(0);
        int position = parent.getChildAdapterPosition(view);
        CityInfo cityInfo = mCityInfoList.get(position);
        // 當前第一個item是它group的最後一個
        // 且view的bottom距離RecyclerView頂端小於Header的高度
        if (cityInfo.isLastInGroup() && view.getBottom() < GROUP_ITEM_TOP) {
            int left = parent.getPaddingLeft();
            int top = view.getBottom() - GROUP_ITEM_TOP;
            int right = parent.getRight() - parent.getPaddingRight();
            int bottom = view.getBottom();
            c.drawRect(left, top, right, bottom, mBackGroundPaint);

            drawText(c, left, top, bottom, cityInfo.getGroup());
        } else {
            int left = parent.getPaddingLeft();
            int top = parent.getPaddingTop();
            int right = parent.getRight() - parent.getPaddingRight();
            int bottom = top + GROUP_ITEM_TOP;
            c.drawRect(left, top, right, bottom, mBackGroundPaint);

            drawText(c, left, top, bottom, cityInfo.getGroup());
        }
    }

代碼介紹到這裏就結束了,如果你對整體的程序感興趣,歡迎去github下載。

三、參考

RecyclerView探索之通過ItemDecoration實現StickyHeader效果

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章