你可能誤會(huì)了身弊!原來自定義LayoutManager可以這么簡單

參考資料

參考資料1;
參考資料2
參考資料3
參考資料4阱佛;

背景介紹

RecyclerView由于其強(qiáng)大的擴(kuò)展性帖汞,現(xiàn)在已經(jīng)逐步的取代了ListViewGridView了。為了實(shí)現(xiàn)不同的布局效果凑术,我們會(huì)用到官方提供的LinearLayoutManager翩蘸、GridLayoutManagerStaggeredGridLayoutManager。但這些布局只能滿足日常需求淮逊,在一些比較復(fù)雜的布局中催首,它們就力不從心了,強(qiáng)行拼湊實(shí)現(xiàn)泄鹏,帶來的后果就是較差的體驗(yàn)和性能郎任。所以能夠自定義LayoutManager還是十分必要的,它能夠解放創(chuàng)造力备籽,構(gòu)造復(fù)雜的舶治、流暢的滑動(dòng)列表。上面幾篇參考資料中就實(shí)現(xiàn)了一些不尋常的效果胶台,我們可以看到歼疮,這些效果如果用常規(guī)的方案去實(shí)現(xiàn)將會(huì)十分蹩腳。

揭開LayoutManager中不為人知的秘密

自定義LayoutManager主要要求我們完成三件事情:

  • 計(jì)算每個(gè)ItemView的位置诈唬;
  • 處理滑動(dòng)事件韩脏;
  • 緩存并重用ItemView;

而我們比較重要的工作是在onLayoutChildern() 這個(gè)回調(diào)方法中完成的铸磅。

下面我們就來一一解析赡矢。

預(yù)先準(zhǔn)備

當(dāng)我們extends RecyclerView.LayoutManager是,我們會(huì)被強(qiáng)制要求重寫generateDefaultLayoutParams()方法阅仔,如方法名字一樣吹散,我們需要提供一個(gè)默認(rèn)的LayoutParams,這里為我們的每個(gè)ItemView提供默認(rèn)的LayoutParams八酒,所以它能夠直接影響到我們的布局效果空民,這里我們設(shè)置成WRAP_CONTENT,讓ItemView獲得決定權(quán)羞迷。

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

計(jì)算ItemView的位置

1.實(shí)現(xiàn)簡單的LayoutManager

先看效果圖:

簡單LayoutManager

再看代碼:

@Override
  public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    super.onLayoutChildren(recycler, state);
    // 先把所有的View先從RecyclerView中detach掉界轩,然后標(biāo)記為"Scrap"狀態(tài),表示這些View處于可被重用狀態(tài)(非顯示中)衔瓮。
    // 實(shí)際就是把View放到了Recycler中的一個(gè)集合中浊猾。
    detachAndScrapAttachedViews(recycler);
    calculateChildrenSite(recycler);
    // 回收和填充Item
    recycleAndFillView(recycler, state);
  }

private void calculateChildrenSite(RecyclerView.Recycler recycler) {
        totalHeight = 0;
        boolean needNew = true;
        int width = 0;
        int height = 0;
        for (int i = 0; i < getItemCount(); i++) {
            // 沒有會(huì)創(chuàng)建
            if (needNew) {
                View view = recycler.getViewForPosition(i);
                measureChildWithMargins(view, 0, 0);
                calculateItemDecorationsForChild(view, new Rect());
                width = getDecoratedMeasuredWidth(view);
                height = getDecoratedMeasuredHeight(view);
                addView(view);
            }
            if (totalHeight > getHeight() + height) {
                needNew = false;
            }
            Rect mTmpRect = allItemRects.get(i);
            if (mTmpRect == null) {
                mTmpRect = new Rect();
            }
            mTmpRect.set(0, totalHeight, DisplayUtils.getScreenWidth(), totalHeight + height);
            totalHeight = totalHeight + height;
            // 保存ItemView的位置信息
            allItemRects.put(i, mTmpRect);
            // 由于之前調(diào)用過detachAndScrapAttachedViews(recycler),所以此時(shí)item都是不可見的
            itemStates.put(i, false);
        }
    }

這段代碼邏輯簡單热鞍,它實(shí)現(xiàn)的其實(shí)就是一個(gè)簡單的垂直線性布局葫慎,當(dāng)然現(xiàn)在還不能滑動(dòng)衔彻,也沒有緩存機(jī)制。在這段代碼中偷办,我們先調(diào)用detachAndScrapAttachedViews(recycler);將所有的ItemView標(biāo)記為Scrap狀態(tài)艰额,然后在挨個(gè)取出來,計(jì)算他們應(yīng)該布局到什么位置椒涯,并用成員變量totalHeight記錄總高度悴晰,最后調(diào)用recycleAndFillView()將ItemView布局上去。

2.兩列式的LayoutManager

先看效果圖:

效果圖

有了上例的基礎(chǔ)逐工,我們只需要稍作調(diào)整铡溪,直接看下面代碼,注意注釋部分泪喊。

private void calculateChildrenSite(RecyclerView.Recycler recycler) {
        totalHeight = 0;
        boolean needNew = true;
        int width = 0;
        int height = 0;
        for (int i = 0; i < getItemCount(); i++) {
            // 沒有會(huì)創(chuàng)建
            if (needNew) {
                View view = recycler.getViewForPosition(i);
                measureChildWithMargins(view, DisplayUtils.getScreenWidth() / 2, 0);
                calculateItemDecorationsForChild(view, new Rect());
                width = getDecoratedMeasuredWidth(view);
                height = getDecoratedMeasuredHeight(view);
                addView(view);
            }
            if (totalHeight > getHeight() + height) {
                needNew = false;
            }
            Rect mTmpRect = allItemRects.get(i);
            if (mTmpRect == null) {
                mTmpRect = new Rect();
            }

            if (i % 2 == 0) { // 當(dāng)i能被2整除時(shí)棕硫,是左,否則是右袒啼。
                // 左
                mTmpRect.set(0, totalHeight, DisplayUtils.getScreenWidth() / 2, totalHeight + height);
            } else {
                // 右哈扮,需要換行
                mTmpRect.set(DisplayUtils.getScreenWidth() / 2, totalHeight, DisplayUtils.getScreenWidth(),
                        totalHeight + height);
                totalHeight = totalHeight + height;
            }
            // 保存ItemView的位置信息
            allItemRects.put(i, mTmpRect);
            // 由于之前調(diào)用過detachAndScrapAttachedViews(recycler),所以此時(shí)item都是不可見的
            itemStates.put(i, false);
        }

    }

處理滑動(dòng)

先來看一下效果:

效果圖

滑動(dòng)事件主要涉及到4個(gè)方法需要重寫蚓再,我們直接來看代碼:

@Override
  public boolean canScrollVertically() {
    //返回true表示可以縱向滑動(dòng)
    return true;
  }

  @Override
  public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    //列表向下滾動(dòng)dy為正滑肉,列表向上滾動(dòng)dy為負(fù),這點(diǎn)與Android坐標(biāo)系保持一致摘仅。
    //實(shí)際要滑動(dòng)的距離
    int travel = dy;

    LogUtils.e("dy = " + dy);
    //如果滑動(dòng)到最頂部
    if (verticalScrollOffset + dy < 0) {
      travel = -verticalScrollOffset;
    } else if (verticalScrollOffset + dy > totalHeight - getVerticalSpace()) {//如果滑動(dòng)到最底部
      travel = totalHeight - getVerticalSpace() - verticalScrollOffset;
    }

    //將豎直方向的偏移量+travel
    verticalScrollOffset += travel;

    // 調(diào)用該方法通知view在y方向上移動(dòng)指定距離
    offsetChildrenVertical(-travel);

    return travel;
  }

  private int getVerticalSpace() {
    //計(jì)算RecyclerView的可用高度靶庙,除去上下Padding值
    return getHeight() - getPaddingBottom() - getPaddingTop();
  }

  @Override
  public boolean canScrollHorizontally() {
    //返回true表示可以橫向滑動(dòng)
    return super.canScrollHorizontally();
  }

  @Override
  public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
    //在這個(gè)方法中處理水平滑動(dòng)
    return super.scrollHorizontallyBy(dx, recycler, state);
  }

緩存并重用ItemView

在上面代碼的基礎(chǔ)上我們稍作改動(dòng),加入緩存娃属,先看下面的log信息六荒,它顯示雖然有100個(gè)Item,但childCount穩(wěn)定在26:

log

下面來看看代碼的變化矾端,我展示了完整的代碼掏击,留心注釋。

public class CustomLayoutManager extends RecyclerView.LayoutManager {
  /** 用于保存item的位置信息 */
  private SparseArray<Rect> allItemRects = new SparseArray<>();
  /** 用于保存item是否處于可見狀態(tài)的信息 */
  private SparseBooleanArray itemStates = new SparseBooleanArray();

  public int totalHeight = 0;
  private int verticalScrollOffset;

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

  @Override
  public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getItemCount() <= 0 || state.isPreLayout()) {
      return;
    }
    super.onLayoutChildren(recycler, state);
    detachAndScrapAttachedViews(recycler);
    /* 這個(gè)方法主要用于計(jì)算并保存每個(gè)ItemView的位置 */
    calculateChildrenSite(recycler);
    recycleAndFillView(recycler, state);
  }

  private void calculateChildrenSite(RecyclerView.Recycler recycler) {
        totalHeight = 0;
        boolean needNew = true;
        int width = 0;
        int height = 0;
        for (int i = 0; i < getItemCount(); i++) {
            // 沒有會(huì)創(chuàng)建
            if (needNew) {
                View view = recycler.getViewForPosition(i);
                measureChildWithMargins(view, DisplayUtils.getScreenWidth() / 2, 0);
                calculateItemDecorationsForChild(view, new Rect());
                width = getDecoratedMeasuredWidth(view);
                height = getDecoratedMeasuredHeight(view);
                addView(view);
            }
            if (totalHeight > getHeight() + height) {
                needNew = false;
            }
            Rect mTmpRect = allItemRects.get(i);
            if (mTmpRect == null) {
                mTmpRect = new Rect();
            }

            if (i % 2 == 0) { // 當(dāng)i能被2整除時(shí)秩铆,是左砚亭,否則是右。
                // 左
                mTmpRect.set(0, totalHeight, DisplayUtils.getScreenWidth() / 2, totalHeight + height);
            } else {
                // 右殴玛,需要換行
                mTmpRect.set(DisplayUtils.getScreenWidth() / 2, totalHeight, DisplayUtils.getScreenWidth(),
                        totalHeight + height);
                totalHeight = totalHeight + height;
            }
            // 保存ItemView的位置信息
            allItemRects.put(i, mTmpRect);
            // 由于之前調(diào)用過detachAndScrapAttachedViews(recycler)捅膘,所以此時(shí)item都是不可見的
            itemStates.put(i, false);
        }

    }


  private void recycleAndFillView(RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getItemCount() <= 0 || state.isPreLayout()) {
      return;
    }

    // 當(dāng)前scroll offset狀態(tài)下的顯示區(qū)域
    Rect displayRect= new Rect(0, verticalScrollOffset, getHorizontalSpace(),
        verticalScrollOffset + getVerticalSpace());

    /**
     * 將滑出屏幕的Items回收到Recycle緩存中
     */
    Rect childRect = new Rect();
    for (int i = 0; i < getItemCount(); i++) {
      //這個(gè)方法獲取的是RecyclerView中的View,注意區(qū)別Recycler中的View
      //這獲取的是實(shí)際的View
      View child = recycler.getViewForPosition(i);
      //下面幾個(gè)方法能夠獲取每個(gè)View占用的空間的位置信息族阅,包括ItemDecorator
      childRect.left = getDecoratedLeft(child);
      childRect.top = getDecoratedTop(child);
      childRect.right = getDecoratedRight(child);
      childRect.bottom = getDecoratedBottom(child);
      //如果Item沒有在顯示區(qū)域篓跛,就說明需要回收
      if (!Rect.intersects(displayRect, childRect)) {
        //移除并回收掉滑出屏幕的View
        removeAndRecycleView(child, recycler);
        itemStates.put(i, false); //更新該View的狀態(tài)為未依附
      }
    }

    //重新顯示需要出現(xiàn)在屏幕的子View
    for (int i = 0; i < getItemCount(); i++) {
      //判斷ItemView的位置和當(dāng)前顯示區(qū)域是否重合
      if (Rect.intersects(displayRect, allItemRects.get(i))) {
        //獲得Recycler中緩存的View
        View itemView = recycler.getViewForPosition(i);
        measureChildWithMargins(itemView, DisplayUtils.getScreenWidth() / 2, 0);
        //添加View到RecyclerView上
        addView(itemView);
        //取出先前存好的ItemView的位置矩形
        Rect rect = allItemRects.get(i);
        //將這個(gè)item布局出來
        layoutDecoratedWithMargins(itemView,
          rect.left,
          rect.top - verticalScrollOffset,  //因?yàn)楝F(xiàn)在是復(fù)用View膝捞,所以想要顯示在
          rect.right,
          rect.bottom - verticalScrollOffset);
        itemStates.put(i, true); //更新該View的狀態(tài)為依附
      }
    }
    LogUtils.e("itemCount = " + getChildCount());
  }


  @Override
  public boolean canScrollVertically() {
    // 返回true表示可以縱向滑動(dòng)
    return true;
  }

  @Override
  public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    //每次滑動(dòng)時(shí)先釋放掉所有的View坦刀,因?yàn)楹竺嬲{(diào)用recycleAndFillView()時(shí)會(huì)重新addView()愧沟。
    detachAndScrapAttachedViews(recycler);
    // 列表向下滾動(dòng)dy為正,列表向上滾動(dòng)dy為負(fù)鲤遥,這點(diǎn)與Android坐標(biāo)系保持一致沐寺。
    // 實(shí)際要滑動(dòng)的距離
    int travel = dy;

    LogUtils.e("dy = " + dy);
    // 如果滑動(dòng)到最頂部
    if (verticalScrollOffset + dy < 0) {
      travel = -verticalScrollOffset;
    } else if (verticalScrollOffset + dy > totalHeight - getVerticalSpace()) {// 如果滑動(dòng)到最底部
      travel = totalHeight - getVerticalSpace() - verticalScrollOffset;
    }
    // 調(diào)用該方法通知view在y方向上移動(dòng)指定距離
    offsetChildrenVertical(-travel);
    recycleAndFillView(recycler, state); //回收并顯示View
    // 將豎直方向的偏移量+travel
    verticalScrollOffset += travel;
    return travel;
  }

  private int getVerticalSpace() {
    // 計(jì)算RecyclerView的可用高度,除去上下Padding值
    return getHeight() - getPaddingBottom() - getPaddingTop();
  }

  @Override
  public boolean canScrollHorizontally() {
    // 返回true表示可以橫向滑動(dòng)
    return super.canScrollHorizontally();
  }

  @Override
  public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
      RecyclerView.State state) {
    // 在這個(gè)方法中處理水平滑動(dòng)
    return super.scrollHorizontallyBy(dx, recycler, state);
  }

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

實(shí)現(xiàn)緩存最主要的就是先把每個(gè)ItemView的位置信息保存起來盖奈,然后在滑動(dòng)過程中通過判斷每個(gè)ItemView的位置是否和當(dāng)前RecyclerView應(yīng)該顯示的區(qū)域有重合混坞,若有就顯示它,若沒有就移除并回收钢坦。

總結(jié)

實(shí)現(xiàn)自己的自定義LayoutManager主要的三個(gè)步驟:

  • 計(jì)算每個(gè)ItemView的位置究孕;
  • 添加滑動(dòng)事件;
  • 實(shí)現(xiàn)緩存爹凹。

我們需根據(jù)代碼多理解厨诸,多思考,然后動(dòng)手寫屬于自己的LayoutManager禾酱。

探討

最近路上留意到很多三輪摩托老司機(jī)開車十分的奔放微酬,和拉力賽有得一拼。之前坐過幾次颤陶,坐的時(shí)候因?yàn)橼s時(shí)間颗管,所以當(dāng)時(shí)感覺老司機(jī)好負(fù)責(zé)。但最近作為路人看滓走,老司機(jī)開車開的太危險(xiǎn)垦江,強(qiáng)行搶道,瘋狂按喇叭...整個(gè)是橫沖直撞的態(tài)勢搅方∫咧啵總之覺得很危險(xiǎn)。
這件事腰懂,你怎么看梗逮?

如果你覺得這篇文章對你有幫助的話,點(diǎn)贊走一走绣溜,再加個(gè)關(guān)注慷彤,互相交流下。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末怖喻,一起剝皮案震驚了整個(gè)濱河市底哗,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌锚沸,老刑警劉巖跋选,帶你破解...
    沈念sama閱讀 221,576評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異哗蜈,居然都是意外死亡前标,警方通過查閱死者的電腦和手機(jī)坠韩,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,515評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來炼列,“玉大人只搁,你說我怎么就攤上這事〖蠹猓” “怎么了氢惋?”我有些...
    開封第一講書人閱讀 168,017評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長稽犁。 經(jīng)常有香客問我焰望,道長,這世上最難降的妖魔是什么已亥? 我笑而不...
    開封第一講書人閱讀 59,626評(píng)論 1 296
  • 正文 為了忘掉前任柿估,我火速辦了婚禮,結(jié)果婚禮上陷猫,老公的妹妹穿的比我還像新娘秫舌。我一直安慰自己,他們只是感情好绣檬,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,625評(píng)論 6 397
  • 文/花漫 我一把揭開白布足陨。 她就那樣靜靜地躺著,像睡著了一般娇未。 火紅的嫁衣襯著肌膚如雪墨缘。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,255評(píng)論 1 308
  • 那天零抬,我揣著相機(jī)與錄音镊讼,去河邊找鬼。 笑死平夜,一個(gè)胖子當(dāng)著我的面吹牛蝶棋,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播忽妒,決...
    沈念sama閱讀 40,825評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼玩裙,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了段直?” 一聲冷哼從身側(cè)響起吃溅,我...
    開封第一講書人閱讀 39,729評(píng)論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎鸯檬,沒想到半個(gè)月后决侈,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,271評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡喧务,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,363評(píng)論 3 340
  • 正文 我和宋清朗相戀三年赖歌,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了枉圃。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,498評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡俏站,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出痊土,到底是詐尸還是另有隱情肄扎,我是刑警寧澤,帶...
    沈念sama閱讀 36,183評(píng)論 5 350
  • 正文 年R本政府宣布赁酝,位于F島的核電站犯祠,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏酌呆。R本人自食惡果不足惜衡载,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,867評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望隙袁。 院中可真熱鬧痰娱,春花似錦、人聲如沸菩收。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,338評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽娜饵。三九已至坡贺,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間箱舞,已是汗流浹背遍坟。 一陣腳步聲響...
    開封第一講書人閱讀 33,458評(píng)論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留晴股,地道東北人愿伴。 一個(gè)月前我還...
    沈念sama閱讀 48,906評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像电湘,于是被迫代替她去往敵國和親公般。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,507評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,283評(píng)論 25 707
  • 基本使用RecyclerView的基本使用并不復(fù)雜胡桨,只需要提供一個(gè)RecyclerView.Apdater的實(shí)現(xiàn)用...
    龐哈哈哈12138閱讀 6,014評(píng)論 2 46
  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫官帘、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,119評(píng)論 4 61
  • 我是詩人 我不是精神病 我是這個(gè)世界的神 詩人是一個(gè)獨(dú)特的存在 什么都可以做不是無病呻吟 一滴露珠可以發(fā)現(xiàn)春天的秘...
    香自苦寒閱讀 264評(píng)論 2 3
  • 這是一部很感人 很感性的電影昧谊,里面描寫的不僅僅地震所帶來的改變,而是在不同時(shí)候刽虹,不同事情, 不同地方時(shí)的人性呢诬,人心...
    blair_c閱讀 100評(píng)論 0 0