Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> 【Android】掌握自定義LayoutManager(二) 實現流式布局

【Android】掌握自定義LayoutManager(二) 實現流式布局

編輯:關於Android編程

一 概述

在開始之前,我想說,如果需求是每個Item寬高一樣,實現起來復雜度比每個Item寬高不一樣的,要小10+倍。然而我們今天要實現的流式布局,恰巧就是至少每個Item的寬度不一樣,所以在計算坐標的時候算的我死去活來。先看一下效果圖:
這裡寫圖片描述
艾瑪,換成妹子圖後貌似好看了許多,我都不認識它了,好吧,項目裡它一般長下面這樣:
這裡寫圖片描述
往常這種效果,我們一般使用自定義ViewGroup實現,我以前也寫了一個。
這不最近再研究自定義LayoutManager麼,想來想去也沒有好的創意,就先拿它開第一刀吧。
(後話:流式布局Item寬度不一,不知不覺給自己挖了個大坑,造成拓展一些功能難度倍增,觀之網上的DEMO,99%Item的大小都是一樣的,so,這個系列的下一篇我計劃 實現一個Item大小一樣 的酷炫LayoutManager。但是最終做成啥樣的效果還沒想好,有朋友看到酷炫的效果可以告訴我,我去高仿一個。)

自定義LayoutManager的步驟:

以本文的流式布局為例,需求是一個垂直滾動的布局,子View以流式排列。先總結一下步驟:

一 實現 generateDefaultLayoutParams()
二 實現 onLayoutChildren()
三 豎直滾動需要 重寫canScrollVertically()和scrollVerticallyBy()

下面我們就一步一步來吧。

二 實現generateDefaultLayoutParams()

如果沒有特殊需求,大部分情況下,我們只需要如下重寫該方法即可。

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    }

RecyclerView.LayoutParams是繼承自android.view.ViewGroup.MarginLayoutParams的,所以可以方便的使用各種margin。

這個方法最終會在recycler.getViewForPosition(i)時調用到,在該方法浩長源碼的最下方:

            final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
            final LayoutParams rvLayoutParams;
            if (lp == null) {
            //這裡會調用mLayout.generateDefaultLayoutParams()為每個ItemView設置LayoutParams
                rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
                holder.itemView.setLayoutParams(rvLayoutParams);
            } else if (!checkLayoutParams(lp)) {
                rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
                holder.itemView.setLayoutParams(rvLayoutParams);
            } else {
                rvLayoutParams = (LayoutParams) lp;
            }
            rvLayoutParams.mViewHolder = holder;
            rvLayoutParams.mPendingInvalidate = fromScrap && bound;
            return holder.itemView;

重寫完這個方法就能編譯通過了,只不過然並卵,界面上是一片空白,下面我們就走進onLayoutChildren()方法 ,為界面添加Item。

注:99%用不到的情況:如果需要存儲一些額外的東西在LayoutParams裡,這裡返回你自定義的LayoutParams即可。
當然,你自定義的LayoutParams需要繼承自RecyclerView.LayoutParams。

三 onLayoutChildren()

該方法是LayoutManager的入口。它會在如下情況下被調用:
1 在RecyclerView初始化時,會被調用兩次
2 在調用adapter.notifyDataSetChanged()時,會被調用。
3 在調用setAdapter替換Adapter時,會被調用。
4 在RecyclerView執行動畫時,它也會被調用。
即RecyclerView 初始化數據源改變時 都會被調用。
(關於初始化時為什麼會被調用兩次,我在系列第一篇文章裡已經分析過。)

在系列開篇我已經提到,它相當於ViewGroup的onLayout()方法,所以我們需要在裡面layout當前屏幕可見的所有子View,千萬不要layout出所有的子View。本文如下編寫:

    private int mVerticalOffset;//豎直偏移量 每次換行時,要根據這個offset判斷
    private int mFirstVisiPos;//屏幕可見的第一個View的Position
    private int mLastVisiPos;//屏幕可見的最後一個View的Position
        @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() == 0) {//沒有Item,界面空著吧
            detachAndScrapAttachedViews(recycler);
            return;
        }
        if (getChildCount() == 0 && state.isPreLayout()) {//state.isPreLayout()是支持動畫的
            return;
        }
        //onLayoutChildren方法在RecyclerView 初始化時 會執行兩遍
        detachAndScrapAttachedViews(recycler);
        //初始化
        mVerticalOffset = 0;
        mFirstVisiPos = 0;
        mLastVisiPos = getItemCount();

        //初始化時調用 填充childView
        fill(recycler, state);
    }

這個fill(recycler, state);方法將是你自定義LayoutManager之旅一生的敵人,簡單的說它承擔了以下任務:
在考慮滑動位移的情況下:
1 回收所有屏幕不可見的子View
2 layout所有可見的子View

在這一節,我們先看一下它的簡單版本,不考慮滑動位移,不考慮滑動方向等,只考慮初始化時,從頭至尾,layout所有可見的子View,在下一節我會配合滑動事件放出它的完整版.

            int topOffset = getPaddingTop();//布局時的上偏移
            int leftOffset = getPaddingLeft();//布局時的左偏移
            int lineMaxHeight = 0;//每一行最大的高度
            int minPos = mFirstVisiPos;//初始化時,我們不清楚究竟要layout多少個子View,所以就假設從0~itemcount-1
            mLastVisiPos = getItemCount() - 1;
            //順序addChildView
            for (int i = minPos; i <= mLastVisiPos; i++) {
                //找recycler要一個childItemView,我們不管它是從scrap裡取,還是從RecyclerViewPool裡取,亦或是onCreateViewHolder裡拿。
                View child = recycler.getViewForPosition(i);
                addView(child);
                measureChildWithMargins(child, 0, 0);
                //計算寬度 包括margin
                if (leftOffset + getDecoratedMeasurementHorizontal(child) <= getHorizontalSpace()) {//當前行還排列的下
                    layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

                    //改變 left  lineHeight
                    leftOffset += getDecoratedMeasurementHorizontal(child);
                    lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
                } else {//當前行排列不下
                    //改變top  left  lineHeight
                    leftOffset = getPaddingLeft();
                    topOffset += lineMaxHeight;
                    lineMaxHeight = 0;

                    //新起一行的時候要判斷一下邊界
                    if (topOffset - dy > getHeight() - getPaddingBottom()) {
                        //越界了 就回收
                        removeAndRecycleView(child, recycler);
                        mLastVisiPos = i - 1;
                    } else {
                        layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

                        //改變 left  lineHeight
                        leftOffset += getDecoratedMeasurementHorizontal(child);
                        lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
                    }
                }
            }

用到的一些工具函數(在系列開篇已介紹過):

    //模仿LLM Horizontal 源碼

    /**
     * 獲取某個childView在水平方向所占的空間
     *
     * @param view
     * @return
     */
    public int getDecoratedMeasurementHorizontal(View view) {
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredWidth(view) + params.leftMargin
                + params.rightMargin;
    }

    /**
     * 獲取某個childView在豎直方向所占的空間
     *
     * @param view
     * @return
     */
    public int getDecoratedMeasurementVertical(View view) {
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredHeight(view) + params.topMargin
                + params.bottomMargin;
    }

    public int getVerticalSpace() {
        return getHeight() - getPaddingTop() - getPaddingBottom();
    }

    public int getHorizontalSpace() {
        return getWidth() - getPaddingLeft() - getPaddingRight();
    }

如上編寫一個超級簡單的fill()方法,運行,你的程序應該就能看到流式布局的效果出現了。
可是千萬別開心,因為痛苦的計算遠沒到來。
如果這些都看不懂,那麼我建議:
一,直接下載完整代碼,配合後面的章節看,看到後面也許前面的就好理解了= =。
二,去學習一下自定義ViewGroup的知識。

此時雖然界面上已經展示了流式布局的效果,可是它並不能滑動,下一節我們讓它動起來。

四,動起來

想讓我們自定義的LayoutManager動起來,最簡單的寫法如下:

    @Override
    public boolean canScrollVertically() {
        return true;
    }

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        int realOffset = dy;//實際滑動的距離, 可能會在邊界處被修復

        offsetChildrenVertical(-realOffset);

        return realOffset;
    }

offsetChildrenVertical(-realOffset);這句話移動所有的childView.
返回值會被RecyclerView用來判斷是否達到邊界, 如果返回值!=傳入的dy,則會有一個邊緣的發光效果,表示到達了邊界。而且返回值還會被RecyclerView用於計算fling效果。

寫完編譯,哇塞,真的跟隨手指滑動了,只不過能動的總共就我們在上一節layout的那些Item,Item並沒有回收,也沒有新的Item出現。

好了,下面開始正經的寫它吧,

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //位移0、沒有子View 當然不移動
        if (dy == 0 || getChildCount() == 0) {
            return 0;
        }

        int realOffset = dy;//實際滑動的距離, 可能會在邊界處被修復
        //邊界修復代碼
        if (mVerticalOffset + realOffset < 0) {//上邊界
            realOffset = -mVerticalOffset;
        } else if (realOffset > 0) {//下邊界
            //利用最後一個子View比較修正
            View lastChild = getChildAt(getChildCount() - 1);
            if (getPosition(lastChild) == getItemCount() - 1) {
                int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
                if (gap > 0) {
                    realOffset = -gap;
                } else if (gap == 0) {
                    realOffset = 0;
                } else {
                    realOffset = Math.min(realOffset, -gap);
                }
            }
        }

        realOffset = fill(recycler, state, realOffset);//先填充,再位移。

        mVerticalOffset += realOffset;//累加實際滑動距離

        offsetChildrenVertical(-realOffset);//滑動

        return realOffset;
    }

這裡用realOffset變量保存實際的位移,也是return 回去的值。大部分情況下它=dy。
在邊界處,為了防止越界,做了一些處理,realOffset 可能不等於dy。
別的文章不同的是,我參考了LinearLayoutManager的源碼,先考慮滑動位移進行View的回收、填充(fill()函數),然後再真正的位移這些子Item。


fill()的過程中

流程:

一 會先考慮到dy回收界面上不可見的Item。
填充布局子View
三 判斷是否將dy都消費掉了,如果消費不掉:例如滑動距離太多,屏幕上的View已經填充完了,仍有空白,那麼就要修正dy給realOffset。

注意事項一:考慮滑動的方向

在填充布局子View的時候,還要考慮滑動的方向,即填充的順序,是從頭至尾填充,還是從尾至頭部填充。
如果是向底部滑動,那麼是順序填充,顯示底端position更大的Item。( dy>0)
如果是向頂部滑動,那麼是逆序填充,顯示頂端positon更小的Item。(dy<0)

注意事項二:流式布局 逆序布局子View的問題

再啰嗦最後一點,我們想象一下這個逆序填充的過程:
正序過程可以自上而下,自左向右layout 子View,每次layout之前判斷當前這一行寬度+子View寬度,是否超過父控件寬度,如果超過了就另起一行。
逆序時,有兩種方案:

1 利用Rect保存子View邊界

正序排列時,保存每個子View的Rect
逆序時,直接拿出來,layout

2 逆序化

自右向左layout子View,每次layout之前判斷當前這一行寬度+子View寬度,是否超過父控件寬度,
如果超過了就另起一行。並且判斷最後一個子View距離父控件左邊的offset,平移這一行的所有子View,較復雜,采用方案1.
(我個人認為這兩個方案都不太好,希望有朋友能提出更好的方案。)
下面上碼:

private SparseArray mItemRects;//key 是View的position,保存View的bounds ,
/**
     * 填充childView的核心方法,應該先填充,再移動。
     * 在填充時,預先計算dy的在內,如果View越界,回收掉。
     * 一般情況是返回dy,如果出現View數量不足,則返回修正後的dy.
     *
     * @param recycler
     * @param state
     * @param dy       RecyclerView給我們的位移量,+,顯示底端, -,顯示頭部
     * @return 修正以後真正的dy(可能剩余空間不夠移動那麼多了 所以return <|dy|)
     */
    private int fill(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) {

        int topOffset = getPaddingTop();

        //回收越界子View
        if (getChildCount() > 0) {//滑動時進來的
            for (int i = getChildCount() - 1; i >= 0; i--) {
                View child = getChildAt(i);
                if (dy > 0) {//需要回收當前屏幕,上越界的View
                    if (getDecoratedBottom(child) - dy < topOffset) {
                        removeAndRecycleView(child, recycler);
                        mFirstVisiPos++;
                        continue;
                    }
                } else if (dy < 0) {//回收當前屏幕,下越界的View
                    if (getDecoratedTop(child) - dy > getHeight() - getPaddingBottom()) {
                        removeAndRecycleView(child, recycler);
                        mLastVisiPos--;
                        continue;
                    }
                }
            }
            //detachAndScrapAttachedViews(recycler);
        }

        int leftOffset = getPaddingLeft();
        int lineMaxHeight = 0;
        //布局子View階段
        if (dy >= 0) {
            int minPos = mFirstVisiPos;
            mLastVisiPos = getItemCount() - 1;
            if (getChildCount() > 0) {
                View lastView = getChildAt(getChildCount() - 1);
                minPos = getPosition(lastView) + 1;//從最後一個View+1開始吧
                topOffset = getDecoratedTop(lastView);
                leftOffset = getDecoratedRight(lastView);
                lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(lastView));
            }
            //順序addChildView
            for (int i = minPos; i <= mLastVisiPos; i++) {
                //找recycler要一個childItemView,我們不管它是從scrap裡取,還是從RecyclerViewPool裡取,亦或是onCreateViewHolder裡拿。
                View child = recycler.getViewForPosition(i);
                addView(child);
                measureChildWithMargins(child, 0, 0);
                //計算寬度 包括margin
                if (leftOffset + getDecoratedMeasurementHorizontal(child) <= getHorizontalSpace()) {//當前行還排列的下
                    layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

                    //保存Rect供逆序layout用
                    Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child) + mVerticalOffset);
                    mItemRects.put(i, rect);

                    //改變 left  lineHeight
                    leftOffset += getDecoratedMeasurementHorizontal(child);
                    lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
                } else {//當前行排列不下
                    //改變top  left  lineHeight
                    leftOffset = getPaddingLeft();
                    topOffset += lineMaxHeight;
                    lineMaxHeight = 0;

                    //新起一行的時候要判斷一下邊界
                    if (topOffset - dy > getHeight() - getPaddingBottom()) {
                        //越界了 就回收
                        removeAndRecycleView(child, recycler);
                        mLastVisiPos = i - 1;
                    } else {
                        layoutDecoratedWithMargins(child, leftOffset, topOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child));

                        //保存Rect供逆序layout用
                        Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + getDecoratedMeasurementHorizontal(child), topOffset + getDecoratedMeasurementVertical(child) + mVerticalOffset);
                        mItemRects.put(i, rect);

                        //改變 left  lineHeight
                        leftOffset += getDecoratedMeasurementHorizontal(child);
                        lineMaxHeight = Math.max(lineMaxHeight, getDecoratedMeasurementVertical(child));
                    }
                }
            }
            //添加完後,判斷是否已經沒有更多的ItemView,並且此時屏幕仍有空白,則需要修正dy
            View lastChild = getChildAt(getChildCount() - 1);
            if (getPosition(lastChild) == getItemCount() - 1) {
                int gap = getHeight() - getPaddingBottom() - getDecoratedBottom(lastChild);
                if (gap > 0) {
                    dy -= gap;
                }

            }

        } else {
            /**
             * ##  利用Rect保存子View邊界
             正序排列時,保存每個子View的Rect,逆序時,直接拿出來layout。
             */
            int maxPos = getItemCount() - 1;
            mFirstVisiPos = 0;
            if (getChildCount() > 0) {
                View firstView = getChildAt(0);
                maxPos = getPosition(firstView) - 1;
            }
            for (int i = maxPos; i >= mFirstVisiPos; i--) {
                Rect rect = mItemRects.get(i);

                if (rect.bottom - mVerticalOffset - dy < getPaddingTop()) {
                    mFirstVisiPos = i + 1;
                    break;
                } else {
                    View child = recycler.getViewForPosition(i);
                    addView(child, 0);//將View添加至RecyclerView中,childIndex為1,但是View的位置還是由layout的位置決定
                    measureChildWithMargins(child, 0, 0);

                    layoutDecoratedWithMargins(child, rect.left, rect.top - mVerticalOffset, rect.right, rect.bottom - mVerticalOffset);
                }
            }
        }


        Log.d("TAG", "count= [" + getChildCount() + "]" + ",[recycler.getScrapList().size():" + recycler.getScrapList().size() + ", dy:" + dy + ",  mVerticalOffset" + mVerticalOffset+", ");

        return dy;
    }

思路已經在前面講解過,代碼裡也配上了注釋,計算坐標等都是數學問題,略饒人,需要用筆在紙上寫一寫,或者運行調試調試。沒啥好辦法。
值得一提的是,可以通過getChildCount()和recycler.getScrapList().size() 查看當前屏幕上的Item數量 和 scrapCache緩存區域的Item數量,合格的LayoutManager,childCount數量不應大於屏幕上顯示的Item數量,而scrapCache緩存區域的Item數量應該是0.

至此我們的自定義LayoutManager已經可以用了,使用的效果就和文首的兩張圖一模一樣。

下面再提及一些其他注意點和適配事項:

五 適配notifyDataSetChanged()

此時會回調onLayoutChildren()函數。因為我們流式布局的特殊性,每個Item的寬度不一致,所以化簡處理,每次這裡歸零。

        //初始化區域
        mVerticalOffset = 0;
        mFirstVisiPos = 0;
        mLastVisiPos = getItemCount();

如果每個Item的大小都一樣,逆序順序layoutChild都比較好處理,則應該在此判斷,getChildCount(),大於0說明是DatasetChanged()操作,(初始化的第二次也會childCount>0)。根據當前記錄的position和位移信息去fill視圖即可。

六 適配 Adapter的替換。

我根據24.2.1源碼,發現網上的資料對這裡的處理其實是不必要的。

一 資料中的做法如下:

當對RecyclerView設置一個新的Adapter時,onAdapterChanged()方法會被回調,一般的做法是在這裡remove掉所有的View。此時onLayoutChildren()方法會被再次調用,一個新的輪回開始。

    @Override
    public void onAdapterChanged(final RecyclerView.Adapter oldAdapter, final RecyclerView.Adapter newAdapter) {
        removeAllViews();
    }

二 我的新觀點:

通過查看源碼+打斷點跟蹤分析,調用RecyclerView.setAdapter後,調用順序依次為

1 Recycler.setAdapter():

    public void setAdapter(Adapter adapter) {
        // bail out if layout is frozen
        setLayoutFrozen(false);
        setAdapterInternal(adapter, false, true); //張旭童注:注意第三個參數是true
        requestLayout();
    }

那麼我們查看setAdapterInternal()方法:

private void setAdapterInternal(Adapter adapter, boolean compatibleWithPrevious,
            boolean removeAndRecycleViews) {
        ...
        //張旭童注:removeAndRecycleViews 參數此時為ture
        if (!compatibleWithPrevious || removeAndRecycleViews) {
            ...
            if (mLayout != null) {
             //張旭童注: 所以如果我們更換Adapter時,mLayout不為空,會先執行如下操作,
                mLayout.removeAndRecycleAllViews(mRecycler);
                mLayout.removeAndRecycleScrapInt(mRecycler);
            }
            // we should clear it here before adapters are swapped to ensure correct callbacks.
            //張旭童注:而且還會清空Recycler的緩存
            mRecycler.clear();
        }
        ...
        if (mLayout != null) {
        //張旭童注:這裡才調用的LayoutManager的方法
            mLayout.onAdapterChanged(oldAdapter, mAdapter);
        }
        //張旭童注:這裡調用Recycler的方法
        mRecycler.onAdapterChanged(oldAdapter, mAdapter, compatibleWithPrevious);
        ...
    }

也就是說 更換Adapter一開始,還沒有執行到LayoutManager.onAdapterChanged(),界面上的View都已經被remove掉了,我們的操作屬於多余的

2 LayoutManager.onAdapterChanged()

空實現:也沒必要實現了

        public void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter) {
        }

3 Recycler.onAdapterChanged():

該方法先清空scapCache區域(貌似也是多余,一開始被清空過了),然後調用RecyclerViewPool.onAdapterChanged()

        void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter,
                boolean compatibleWithPrevious) {
            clear();
            getRecycledViewPool().onAdapterChanged(oldAdapter, newAdapter, compatibleWithPrevious);
        }

        public void clear() {
            mAttachedScrap.clear();
            recycleAndClearCachedViews();
        }

4 RecyclerViewPool.onAdapterChanged()

如果沒有別的Adapter在用這個RecyclerViewPool,會清空RecyclerViewPool的緩存。

        void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter,
                boolean compatibleWithPrevious) {
            if (oldAdapter != null) {
                detach();
            }
            if (!compatibleWithPrevious && mAttachCount == 0) {
                clear();
            }
            if (newAdapter != null) {
                attach(newAdapter);
            }
        }

5 LayoutManager.onLayoutChildren()

新的布局開始。

七 總結:

引用一段話

They are also extremely complex, and hard to get right. For every amount of effort RecyclerView requires of you, it is doing 10x more behind the scenes.

本文Demo仍有很大完善空間,有些需要完善的細節非常復雜,需要經過多次試驗才能得到正確的結果(這裡我更加敬佩Google提供的三個LM)。每一個我們想要實現的需求,可能要花費比我們想象的時間*10倍的時間。
上篇也提及到的,不要過度優化,達成需求就好。

可以通過getChildCount()和recycler.getScrapList().size() 查看當前屏幕上的Item數量 和 scrapCache緩存區域的Item數量,合格的LayoutManager,childCount數量不應大於屏幕上顯示的Item數量,而scrapCache緩存區域的Item數量應該是0.
官方的LayoutManager都是達標的,本例也是達標的,網上大部分文章的Demo,都是不合格的。。

感興趣的同學可以對網上的各個Demo打印他們onCreateViewHolder執行的次數,以及上述兩個參數的值,和官方的LayoutManager比較,這三個參數先達標,才算是及格的LayoutManager,但後續優化之路仍很長。

本系列文章相關代碼傳送門:
自定義LayoutManager實現的流式布局

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved