LayoutManager分析與實踐

自從RecyclerView出來之后就得到廣泛的應用,但是由于其使用和定制化比較復雜,所以很多時候都停留在使用階段上。這片文章簡單講述一個自定義一個簡單的LayoutManager.

翻譯

/**
 * A <code>LayoutManager</code> is responsible for measuring and positioning item views
 * within a <code>RecyclerView</code> as well as determining the policy for when to recycle
 * item views that are no longer visible to the user. By changing the <code>LayoutManager</code>
 * a <code>RecyclerView</code> can be used to implement a standard vertically scrolling list,
 * a uniform grid, staggered grids, horizontally scrolling collections and more. Several stock
 * layout managers are provided for general use.
 * <p/>
 * If the LayoutManager specifies a default constructor or one with the signature
 * ({@link Context}, {@link AttributeSet}, {@code int}, {@code int}), RecyclerView will
 * instantiate and set the LayoutManager when being inflated. Most used properties can
 * be then obtained from {@link #getProperties(Context, AttributeSet, int, int)}. In case
 * a LayoutManager specifies both constructors, the non-default constructor will take
 * precedence.
 *
 */

翻譯:

一個LayoutManager負責測量和定位RecyclerView的item views, 同時需要處理在item views不再可見時的回收工作。通過設置不同的LayoutManager, RecyclerView可以用來實現標準的縱向滑動列表,通用的網格、交錯型、橫向滑動等效果。這幾種LayoutManager都已經提供了。

如果一個LayoutManager設定了默認的構造器或者一個參數分別為Context, AttributeSet, int, int的構造器,RecyclerView可以通過xml來進行實例化該LayoutManager, 不用在代碼中設置。可以在LayoutManager構造器中使用方法getProperties(Context, AttributeSet, int, int)來獲取自定義的屬性。如果兩種構造器都制定了,那么優先使用默認的構造器。

功能

從官方文檔中可以看出,一個LayoutManager的工作是:測量、定位和回收RecyclerView的item views. 簡而言之,LayoutManager實際上就是讓item views合理的顯示在RecyclerView, 同時需要進行回收。這些工作和定義一個使用了回收機制的ViewGroup非常相似,就像ListView一樣,不過它并不考慮數據的綁定。

定義一個有View回收機制的ViewGroup需要處理的工作:

  • 測量(measure)
  • 布局(定位, layout)
  • 考慮滑動
  • 回收View

這里我們先討論一下在LayoutManager需要怎么處理這些工作。

  • 測量:獲取到View后,需要對其進行測量,因為復用的原因,所以這一步必須做
  • 布局:對獲取到的View根據要求放置在界面上,同時需要考慮滑動
  • 滑動:touch的處理是由RecyclerView來完成的,LayoutManager需要處理的是決定滑動方向和修正滑動距離
  • 回收View:關于如何回收View都是放在Recycler中的,LayoutManager需要告訴那些View需要被回收

接下來說明一下LayoutManager做這些工作可能使用到的方法。

測量

獲取到View的過程完全是交由Recycler來完成,LinearLayoutManager并不關心它是重新構造,還是從緩存中獲取的,然后對其測量即可。不過另外需要注意的是View的margin尺寸。

下面是測量方法和另外兩個計算一個child view布局時所占用的空間尺寸的方法,考慮到了margin:

/**
 * Measure a child view using standard measurement policy, taking the padding
 * of the parent RecyclerView, any added item decorations and the child margins
 * into account.
 *
 * <p>If the RecyclerView can be scrolled in either dimension the caller may
 * pass 0 as the widthUsed or heightUsed parameters as they will be irrelevant.</p>
 *
 * @param child Child view to measure
 * @param widthUsed Width in pixels currently consumed by other views, if relevant
 * @param heightUsed Height in pixels currently consumed by other views, if relevant
 */
public void measureChildWithMargins(View child, int widthUsed, int heightUsed) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();

    final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
    widthUsed += insets.left + insets.right;
    heightUsed += insets.top + insets.bottom;

    final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
            getPaddingLeft() + getPaddingRight() +
                    lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
            canScrollHorizontally());
    final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
            getPaddingTop() + getPaddingBottom() +
                    lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
            canScrollVertically());
    if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
        child.measure(widthSpec, heightSpec);
    }
}

/**
 * 獲取 child view 橫向上需要占用的空間,margin計算在內
 *
 * @param view item view
 * @return child view 橫向占用的空間
 */
private int getDecoratedMeasurementHorizontal(View view) {
    final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
            view.getLayoutParams();
    return getDecoratedMeasuredWidth(view) + params.leftMargin
            + params.rightMargin;
}

/**
 * 獲取 child view 縱向上需要占用的空間,margin計算在內
 *
 * @param view item view
 * @return child view 縱向占用的空間
 */
private int getDecoratedMeasurementVertical(View view) {
    final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
    return getDecoratedMeasuredHeight(view) + params.topMargin + params.bottomMargin;
}

因為ItemDecoration的存在,所以獲取尺寸時使用的是getDecoratedMeasuredWidth(View)getDecoratedMeasuredHeight(View), 將ItemDecoration考慮在內了。

布局

布局是LayoutManager最基本的功能,同時有比較復雜的尺寸結算。如果對計算不那么排斥或有比較好的計算方法的話,實際上也很簡單。

下面是LayoutManager對一個child view進行布局(定位)的方法:

/**
 * Lay out the given child view within the RecyclerView using coordinates that
 * include any current {@link ItemDecoration ItemDecorations} and margins.
 *
 * <p>LayoutManagers should prefer working in sizes and coordinates that include
 * item decoration insets whenever possible. This allows the LayoutManager to effectively
 * ignore decoration insets within measurement and layout code. See the following
 * methods:</p>
 * <ul>
 *     <li>{@link #layoutDecorated(View, int, int, int, int)}</li>
 *     <li>{@link #measureChild(View, int, int)}</li>
 *     <li>{@link #measureChildWithMargins(View, int, int)}</li>
 *     <li>{@link #getDecoratedLeft(View)}</li>
 *     <li>{@link #getDecoratedTop(View)}</li>
 *     <li>{@link #getDecoratedRight(View)}</li>
 *     <li>{@link #getDecoratedBottom(View)}</li>
 *     <li>{@link #getDecoratedMeasuredWidth(View)}</li>
 *     <li>{@link #getDecoratedMeasuredHeight(View)}</li>
 * </ul>
 *
 * @param child Child to lay out
 * @param left Left edge, with item decoration insets and left margin included
 * @param top Top edge, with item decoration insets and top margin included
 * @param right Right edge, with item decoration insets and right margin included
 * @param bottom Bottom edge, with item decoration insets and bottom margin included
 *
 * @see View#layout(int, int, int, int)
 * @see #layoutDecorated(View, int, int, int, int)
 */
public void layoutDecoratedWithMargins(View child, int left, int top, int right,
        int bottom) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    final Rect insets = lp.mDecorInsets;
    child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
            right - insets.right - lp.rightMargin,
            bottom - insets.bottom - lp.bottomMargin);
}

注釋翻譯:

RecyclerView中定位一個child view, 考慮了ItemDecoration和margin的影響。

LayoutManager應該盡可能地更加關注包含ItemDecoration嵌入時的尺寸和定位。這個方法允許LayoutManager不用考慮受ItemDecoration影響的尺寸和布局代碼,獲取的直接是最終結果。

參考的方法:忽略。

參數

  • child: child view
  • left, 左側邊距,包括ItemDecoration和margin
  • top, 上邊距
  • right, 右邊距
  • bottom, 下邊距

可以看出,這個方法布局時需要考慮ItemDecoration的影響和margin, 而進行測量的時候其實已經包括了ItemDecoration的影響和margin,我們就不再做過多的計算工作。

滑動

滑動有兩個方向,一般允許一個方向的滑動,實際上,也可以同時允許兩個方向上的滑動,不過一般沒有這個需求。

下面是考慮滑動時可能需要處理的幾個方法:

/**
 * Scroll horizontally by dx pixels in screen coordinates and return the distance traveled.
 * The default implementation does nothing and returns 0.
 *
 * @param dx            distance to scroll by in pixels. X increases as scroll position
 *                      approaches the right.
 * @param recycler      Recycler to use for fetching potentially cached views for a
 *                      position
 * @param state         Transient state of RecyclerView
 * @return The actual distance scrolled. The return value will be negative if dx was
 * negative and scrolling proceeeded in that direction.
 * <code>Math.abs(result)</code> may be less than dx if a boundary was reached.
 */
public int scrollHorizontallyBy(int dx, Recycler recycler, State state) {
    return 0;
}

/**
 * Scroll vertically by dy pixels in screen coordinates and return the distance traveled.
 * The default implementation does nothing and returns 0.
 *
 * @param dy            distance to scroll in pixels. Y increases as scroll position
 *                      approaches the bottom.
 * @param recycler      Recycler to use for fetching potentially cached views for a
 *                      position
 * @param state         Transient state of RecyclerView
 * @return The actual distance scrolled. The return value will be negative if dy was
 * negative and scrolling proceeeded in that direction.
 * <code>Math.abs(result)</code> may be less than dy if a boundary was reached.
 */
public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
    return 0;
}

/**
 * Query if horizontal scrolling is currently supported. The default implementation
 * returns false.
 *
 * @return True if this LayoutManager can scroll the current contents horizontally
 */
public boolean canScrollHorizontally() {
    return false;
}

/**
 * Query if vertical scrolling is currently supported. The default implementation
 * returns false.
 *
 * @return True if this LayoutManager can scroll the current contents vertically
 */
public boolean canScrollVertically() {
    return false;
}

注釋翻譯:

  • scrollHorizontallyBy(int, Recycler, State): 根據屏幕橫向滑動的距離dx計算實際滑動的距離。默認不進行滑動,返回值為0
  • scrollVerticallyBy(int, Recycler, State): 根據屏幕縱向滑動的距離dy計算實際滑動的距離。默認不進行滑動,返回值為0
  • canScrollHorizontally(): 查詢目前是否支持橫向滑動。默認返回false
  • canScrollVertically(): 查詢目前是否支持橫向滑動。默認返回false

方法canScrollHorizontally()canScrollVertically()是判斷是否處理RecyclerView上的滑動,這個根據需求自行實現。真正處理滑動的是在scrollHorizontallyBy(int, Recycler, State)scrollVerticallyBy(int, Recycler, State)中,手指滑動距離作為參數傳入這兩個方法中,而根據邏輯,處理滑動,最后返回處理后的滑動距離,一般情況下,如果沒有到達邊界,那么處理后的滑動距離和實際滑動距離是一樣的,到達邊界時對滑動距離進行修正。

另外需要注意的是在scrollHorizontallyBy(int, Recycler, State)scrollVerticallyBy(int, Recycler, State)中需要進行滑動時的布局問題,即滑動一定距離之后,實際上是重新進行了布局的。

回收View

回收View原因和原理是,在根據邏輯布局View時,它超出了用戶的可視范圍,所以為了性能考慮,我們應該及時進行回收,避免耗費過多的內存。而通常的處理方法是,在用戶的可視范圍上進行布局,查看超出邊界的child view, 然后進行回收,當然,及時發現不可能顯示在界面上的child view, 在布局過程中就可以決定是否需要對余下的child view進行布局。

概念

RecyclerView的概念中,有幾種對回收的概念

  • attach/detach: 將item view添加到RecyclerView中,和add/remove不同的是不會觸發layout
  • scrap: 標識一個item view表示其已經從RecyclerView中移除,但是實際上還是在RecyclerView
  • recycle: 表示一個沒有parent的View的回收處理工作,可以銷毀,也可以用來復用
/**
 * Temporarily detach and scrap all currently attached child views. Views will be scrapped
 * into the given Recycler. The Recycler may prefer to reuse scrap views before
 * other views that were previously recycled.
 *
 * @param recycler Recycler to scrap views into
 */
public void detachAndScrapAttachedViews(Recycler recycler) {
    final int childCount = getChildCount();
    for (int i = childCount - 1; i >= 0; i--) {
        final View v = getChildAt(i);
        scrapOrRecycleView(recycler, i, v);
    }
}

注釋翻譯:

臨時detach和scrap所有的child view. 這些view將被放置到Recycler中處理。相對于之前回收的那些view, Recycler會首先使用利用這些views.

除了回收所有的child view之外,還有很多其他的處理child view的方法,全部列舉在下面,就不再一一翻譯,可能會用到的一個方法removeAndRecycleView(View, Recycler), 表示移除一個child view, 并交由指定的Recycler處理。

/**
 * Temporarily detach a child view.
 *
 * <p>LayoutManagers may want to perform a lightweight detach operation to rearrange
 * views currently attached to the RecyclerView. Generally LayoutManager implementations
 * will want to use {@link #detachAndScrapView(android.view.View, RecyclerView.Recycler)}
 * so that the detached view may be rebound and reused.</p>
 *
 * <p>If a LayoutManager uses this method to detach a view, it <em>must</em>
 * {@link #attachView(android.view.View, int, RecyclerView.LayoutParams) reattach}
 * or {@link #removeDetachedView(android.view.View) fully remove} the detached view
 * before the LayoutManager entry point method called by RecyclerView returns.</p>
 *
 * @param child Child to detach
 */
public void detachView(View child) {
    final int ind = mChildHelper.indexOfChild(child);
    if (ind >= 0) {
        detachViewInternal(ind, child);
    }
}

/**
 * Temporarily detach a child view.
 *
 * <p>LayoutManagers may want to perform a lightweight detach operation to rearrange
 * views currently attached to the RecyclerView. Generally LayoutManager implementations
 * will want to use {@link #detachAndScrapView(android.view.View, RecyclerView.Recycler)}
 * so that the detached view may be rebound and reused.</p>
 *
 * <p>If a LayoutManager uses this method to detach a view, it <em>must</em>
 * {@link #attachView(android.view.View, int, RecyclerView.LayoutParams) reattach}
 * or {@link #removeDetachedView(android.view.View) fully remove} the detached view
 * before the LayoutManager entry point method called by RecyclerView returns.</p>
 *
 * @param index Index of the child to detach
 */
public void detachViewAt(int index) {
    detachViewInternal(index, getChildAt(index));
}

private void detachViewInternal(int index, View view) {
    if (DISPATCH_TEMP_DETACH) {
        ViewCompat.dispatchStartTemporaryDetach(view);
    }
    mChildHelper.detachViewFromParent(index);
}

/**
 * Reattach a previously {@link #detachView(android.view.View) detached} view.
 * This method should not be used to reattach views that were previously
 * {@link #detachAndScrapView(android.view.View, RecyclerView.Recycler)}  scrapped}.
 *
 * @param child Child to reattach
 * @param index Intended child index for child
 * @param lp LayoutParams for child
 */
public void attachView(View child, int index, LayoutParams lp) {
    ViewHolder vh = getChildViewHolderInt(child);
    if (vh.isRemoved()) {
        mRecyclerView.mViewInfoStore.addToDisappearedInLayout(vh);
    } else {
        mRecyclerView.mViewInfoStore.removeFromDisappearedInLayout(vh);
    }
    mChildHelper.attachViewToParent(child, index, lp, vh.isRemoved());
    if (DISPATCH_TEMP_DETACH)  {
        ViewCompat.dispatchFinishTemporaryDetach(child);
    }
}

/**
 * Reattach a previously {@link #detachView(android.view.View) detached} view.
 * This method should not be used to reattach views that were previously
 * {@link #detachAndScrapView(android.view.View, RecyclerView.Recycler)}  scrapped}.
 *
 * @param child Child to reattach
 * @param index Intended child index for child
 */
public void attachView(View child, int index) {
    attachView(child, index, (LayoutParams) child.getLayoutParams());
}

/**
 * Reattach a previously {@link #detachView(android.view.View) detached} view.
 * This method should not be used to reattach views that were previously
 * {@link #detachAndScrapView(android.view.View, RecyclerView.Recycler)}  scrapped}.
 *
 * @param child Child to reattach
 */
public void attachView(View child) {
    attachView(child, -1);
}

/**
 * Finish removing a view that was previously temporarily
 * {@link #detachView(android.view.View) detached}.
 *
 * @param child Detached child to remove
 */
public void removeDetachedView(View child) {
    mRecyclerView.removeDetachedView(child, false);
}

/**
 * Moves a View from one position to another.
 *
 * @param fromIndex The View's initial index
 * @param toIndex The View's target index
 */
public void moveView(int fromIndex, int toIndex) {
    View view = getChildAt(fromIndex);
    if (view == null) {
        throw new IllegalArgumentException("Cannot move a child from non-existing index:"
                + fromIndex);
    }
    detachViewAt(fromIndex);
    attachView(view, toIndex);
}

/**
 * Detach a child view and add it to a {@link Recycler Recycler's} scrap heap.
 *
 * <p>Scrapping a view allows it to be rebound and reused to show updated or
 * different data.</p>
 *
 * @param child Child to detach and scrap
 * @param recycler Recycler to deposit the new scrap view into
 */
public void detachAndScrapView(View child, Recycler recycler) {
    int index = mChildHelper.indexOfChild(child);
    scrapOrRecycleView(recycler, index, child);
}

/**
 * Detach a child view and add it to a {@link Recycler Recycler's} scrap heap.
 *
 * <p>Scrapping a view allows it to be rebound and reused to show updated or
 * different data.</p>
 *
 * @param index Index of child to detach and scrap
 * @param recycler Recycler to deposit the new scrap view into
 */
public void detachAndScrapViewAt(int index, Recycler recycler) {
    final View child = getChildAt(index);
    scrapOrRecycleView(recycler, index, child);
}

/**
 * Remove a child view and recycle it using the given Recycler.
 *
 * @param child Child to remove and recycle
 * @param recycler Recycler to use to recycle child
 */
public void removeAndRecycleView(View child, Recycler recycler) {
    removeView(child);
    recycler.recycleView(child);
}

/**
 * Remove a child view and recycle it using the given Recycler.
 *
 * @param index Index of child to remove and recycle
 * @param recycler Recycler to use to recycle child
 */
public void removeAndRecycleViewAt(int index, Recycler recycler) {
    final View view = getChildAt(index);
    removeViewAt(index);
    recycler.recycleView(view);
}

實現FlowLayoutManger

看過很多例子,在學習LayoutManager的時候通常都是以FlowLayoutManager來作為實踐的,因為我們對其比較了解,而且也沒有更好的示例來作為練習。

FlowLayoutManager的功能

  • 每個item view從第一行開始進行橫向排列,當一行不足顯示下一個item的時候,將其布局在下一行
  • 支持縱向滑動(也可以支持橫向滑動,不過目前不在考慮范圍內)
  • 每個item高度不一致的考慮

構造器

根據前面的闡述,我們實現兩個構造器,分別用于代碼構造和xml中使用。

public FlowLayoutManager() {
    setAutoMeasureEnabled(true);
}

public FlowLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    setAutoMeasureEnabled(true);
}

注意setAutoMeasureEnabled(boolean)是表明RecyclerView的布局是否交由LayoutManager進行處理,否則應該重寫LayoutManager#onMeasure(int, int)來自定義測量的實現。一般傳true, 除非有特殊需求。

實現默認方法

LayoutManager必須實現這個方法。

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

就是生成默認的LayoutParams, 參考LinearLayoutManager, 除非有特殊需要再改變。

暫時不考慮滑動進行布局

只考慮第一次進行布局時

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getItemCount() == 0) {
        detachAndScrapAttachedViews(recycler);
        return;
    }

    // 如果正在進行動畫,則不進行布局
    if (getChildCount() == 0 && state.isPreLayout()) {
        return;
    }

    detachAndScrapAttachedViews(recycler);

    // 進行布局
    layout(recycler, state);
}

/**
 * 布局操作
 *
 * @param recycler
 * @param state
 */
private void layout(RecyclerView.Recycler recycler, RecyclerView.State state) {

    // 縱向計算偏移量,考慮padding
    int topOffset = getPaddingTop();
    // 橫向計算偏移量,考慮padding
    int leftOffset = getPaddingLeft();
    // 行高,以最高的item作為參考
    int maxLineHeight = 0;

    final int childCount = getChildCount();

    // 當第一次進行布局時
    if (childCount == 0) {
        for (int i = 0; i < getItemCount(); i++) {
            // 獲取一個item view, 添加到RecyclerView中,進行測量、布局
            final View itemView = recycler.getViewForPosition(i);
            addView(itemView);
            // 測量,獲取尺寸
            measureChildWithMargins(itemView, 0, 0);
            final int sizeHorizontal = getDecoratedMeasurementHorizontal(itemView);
            final int sizeVertical = getDecoratedMeasurementVertical(itemView);
            // 進行布局
            if (leftOffset + sizeHorizontal <= getHorizontalSpace()) {
                // 如果這行能夠布局,則往后排
                // layout
                layoutDecoratedWithMargins(itemView, leftOffset, topOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical);

                // 修正橫向計算偏移量
                leftOffset += sizeHorizontal;
                maxLineHeight = Math.max(maxLineHeight, sizeVertical);
            } else {
                // 如果當前行不夠,則往下一行挪
                // 修正計算偏移量、行高
                topOffset += maxLineHeight;
                maxLineHeight = 0;
                leftOffset = getPaddingLeft();

                // layout
                if (topOffset > getHeight() - getPaddingBottom()) {
                    // 如果超出下邊界
                    // 移除并回收該item view
                    removeAndRecycleView(itemView, recycler);
                } else {
                    // 如果沒有超出下邊界,則繼續布局
                    layoutDecoratedWithMargins(itemView, leftOffset, topOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical);
                    // 修正計算偏移量、行高
                    leftOffset += sizeHorizontal;
                    maxLineHeight = Math.max(maxLineHeight, sizeVertical);
                }
            }

        }
    } else {
        // nothing
    }
}

//...
/**
 * @return 橫向的可布局的空間
 */
private int getHorizontalSpace() {
    return getWidth() - getPaddingLeft() - getPaddingRight();
}

考慮滑動

因為是縱向滑動,我們將RecyclerView想象成一個寬度一定,長度可變的紙張,長度有其中的item布局來決定。當手指滑動屏幕時,實際上是讓紙張在上面移動,同時保證紙張頂部不能低于RecyclerView頂部,紙張底部不能高于RecyclerView底部。

布局亦是這樣,我們假定所有的item view都已經布局在張紙上面(不考慮回收),我們滑動時,只是改變了這張紙相對于屏幕的滑動距離。

不考慮邊界
/**
 * @return 可以縱向滑動
 */
@Override
public boolean canScrollVertically() {
    return true;
}

// 縱向偏移量
private int mVerticalOffset = 0;

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    Log.e(TAG, String.valueOf(dy));
    // 如果滑動距離為0, 或是沒有任何item view, 則不移動
    if (dy == 0 || getChildCount() == 0) {
        return 0;
    }

    mVerticalOffset += dy;

    detachAndScrapAttachedViews(recycler);

    layout(recycler, state, mVerticalOffset);

    return dy;
}

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getItemCount() == 0) {
        detachAndScrapAttachedViews(recycler);
        return;
    }

    // 如果正在進行動畫,則不進行布局
    if (getChildCount() == 0 && state.isPreLayout()) {
        return;
    }

    detachAndScrapAttachedViews(recycler);

    // 進行布局
    layout(recycler, state, 0);
}

/**
 * 布局操作
 *
 * @param recycler
 * @param state
 */
private void layout(RecyclerView.Recycler recycler, RecyclerView.State state, int verticalOffset) {

    // 縱向計算偏移量,考慮padding
    int topOffset = getPaddingTop();
    // 橫向計算偏移量,考慮padding
    int leftOffset = getPaddingLeft();
    // 行高,以最高的item作為參考
    int maxLineHeight = 0;

    for (int i = 0; i < getItemCount(); i++) {
        // 獲取一個item view, 添加到RecyclerView中,進行測量、布局
        final View itemView = recycler.getViewForPosition(i);
        addView(itemView);
        // 測量,獲取尺寸
        measureChildWithMargins(itemView, 0, 0);
        final int sizeHorizontal = getDecoratedMeasurementHorizontal(itemView);
        final int sizeVertical = getDecoratedMeasurementVertical(itemView);
        // 進行布局
        if (leftOffset + sizeHorizontal <= getHorizontalSpace()) {
            // 如果這行能夠布局,則往后排
            // layout
            layoutDecoratedWithMargins(itemView, leftOffset, topOffset - verticalOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical - verticalOffset);

            // 修正橫向計算偏移量
            leftOffset += sizeHorizontal;
            maxLineHeight = Math.max(maxLineHeight, sizeVertical);
        } else {
            // 如果當前行不夠,則往下一行挪
            // 修正計算偏移量、行高
            topOffset += maxLineHeight;
            maxLineHeight = 0;
            leftOffset = getPaddingLeft();

            // layout
            // 不考慮邊界
            layoutDecoratedWithMargins(itemView, leftOffset, topOffset - verticalOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical - verticalOffset);
            // 修正計算偏移量、行高
            leftOffset += sizeHorizontal;
            maxLineHeight = Math.max(maxLineHeight, sizeVertical);
        }
    }
}

上面示例,對滑動的處理比較簡單,記錄總共滑動的距離,并在定位時將滑動距離計算上。

考慮邊界

對于FlowLayoutManager來說,上邊界比較容易處理,下邊界的處理則需要稍加處理。

上邊界:因為每一行的頂部都是相同的,所以手指下滑時,我們考慮第一行的頂部是否已經到達上邊界,取第一個item即可。

下邊界:每個item的高度不一定相同,為每一個item進行布局時,這樣不會出現問題,但是在手指上滑時,判斷是否到達下邊界,需要知道最后一行中最高的item是多少,所以在計算的時候,要將最后一行的所有item考慮在內。

注:我們認為,一旦一個item構造完成,那么它的尺寸是不應該發生變化的,如果在滑動過程中,尺寸發生變化,會影響到布局的計算。

一個概念和tip: RecyclerView就是一個ViewGroup, 而通常顯示的順序,也是FlowLayoutManagerlayout的順序都是從第一個開始的,所以可以這樣認為,在position較大的item view顯示之前,較小position的item view都是經過測量和布局過的。我們可以將布局過的item view的位置保存,在手指下滑,即加載position較小的item view時,不再對其進行測量和布局,只需要取出其位置,使用滑動修正當前的位置即可。

但是會如果數據改變,item view的尺寸和布局則會發生改變,原先的記錄則會被認為是臟數據,所以當任何數據改變時,需要對其修正。不過一個好的修正方案需要有好的策略,在實現中,我只采用了清理的方法,暫時不能提供更加理想化的策略。

下面是完整代碼:

import android.content.Context;
import android.graphics.Rect;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;

/**
 * Created by Mycroft on 2017/1/11.
 */
public final class FlowLayoutManager extends RecyclerView.LayoutManager {

    private static final String TAG = FlowLayoutManager.class.getSimpleName();

    public FlowLayoutManager() {
        setAutoMeasureEnabled(true);
    }

    public FlowLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        setAutoMeasureEnabled(true);
    }

    @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) {
            detachAndScrapAttachedViews(recycler);
            return;
        }

        // 如果正在進行動畫,則不進行布局
        if (getChildCount() == 0 && state.isPreLayout()) {
            return;
        }

        detachAndScrapAttachedViews(recycler);

        // 進行布局
        layout(recycler, state, 0);
    }

    /**
     * @return 可以縱向滑動
     */
    @Override
    public boolean canScrollVertically() {
        return true;
    }

    // 縱向偏移量
    private int mVerticalOffset = 0;

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 如果滑動距離為0, 或是沒有任何item view, 則不移動
        if (dy == 0 || getChildCount() == 0) {
            return 0;
        }

        // 實際滑動的距離,到達邊界時需要進行修正
        int realOffset = dy;
        if (mVerticalOffset + realOffset < 0) {
            realOffset = -mVerticalOffset;
        } else if (realOffset > 0) {
            // 手指上滑,判斷是否到達下邊界
            final View lastChildView = getChildAt(getChildCount() - 1);
            if (getPosition(lastChildView) == getItemCount() - 1) {
                int maxBottom = getDecoratedBottom(lastChildView);

                int lastChildTop = getDecoratedTop(lastChildView);
                for (int i = getChildCount() - 2; i >= 0; i--) {
                    final View child = getChildAt(i);
                    if (getDecoratedTop(child) == lastChildTop) {
                        maxBottom = Math.max(maxBottom, getDecoratedBottom(getChildAt(i)));
                    } else {
                        break;
                    }
                }

                int gap = getHeight() - getPaddingBottom() - maxBottom;
                if (gap > 0) {
                    realOffset = -gap;
                } else if (gap == 0) {
                    realOffset = 0;
                } else {
                    realOffset = Math.min(realOffset, -gap);
                }
            }
        }

        realOffset = layout(recycler, state, realOffset);

        mVerticalOffset += realOffset;

        offsetChildrenVertical(-realOffset);

        return realOffset;
    }

    private final SparseArray<Rect> mItemRects = new SparseArray<>();

    /**
     * 布局操作
     *
     * @param recycler
     * @param state
     * @param dy       用于判斷回收、顯示item, 對布局/定位本身沒有影響
     * @return
     */
    private int layout(RecyclerView.Recycler recycler, RecyclerView.State state, int dy) {

        int firstVisiblePos = 0;

        // 縱向計算偏移量,考慮padding
        int topOffset = getPaddingTop();
        // 橫向計算偏移量,考慮padding
        int leftOffset = getPaddingLeft();
        // 行高,以最高的item作為參考
        int maxLineHeight = 0;

        int childCount = getChildCount();

        // 當是滑動進入時(在onLayoutChildren方法里面,我們移除了所有的child view, 所以只有可能從scrollVerticalBy方法里面進入這個方法)
        if (childCount > 0) {
            // 計算滑動后,需要被回收的child view

            if (dy > 0) {
                // 手指上滑,可能需要回收頂部的view
                for (int i = 0; i < childCount; i++) {
                    final View child = getChildAt(i);
                    if (getDecoratedBottom(child) - dy < topOffset) {
                        // 超出頂部的item
                        removeAndRecycleView(child, recycler);
                        i--;
                        childCount--;
                    } else {
                        firstVisiblePos = i;
                        break;
                    }
                }
            } else if (dy < 0) {
                // 手指下滑,可能需要回收底部的view
                for (int i = childCount - 1; i >= 0; i--) {
                    final View child = getChildAt(i);
                    if (getDecoratedTop(child) - dy > getHeight() - getPaddingBottom()) {
                        // 超出底部的item
                        removeAndRecycleView(child, recycler);
                    } else {
                        break;
                    }
                }
            }
        }

        // 進行布局
        if (dy >= 0) {
            // 手指上滑,按順序布局item

            int minPosition = firstVisiblePos;
            if (getChildCount() > 0) {
                final View lastVisibleChild = getChildAt(getChildCount() - 1);
                // 修正當前偏移量
                topOffset = getDecoratedTop(lastVisibleChild);
                leftOffset = getDecoratedRight(lastVisibleChild);
                // 修正第一個應該進行布局的item view
                minPosition = getPosition(lastVisibleChild) + 1;

                // 使用排在最后一行的所有的child view進行高度修正
                maxLineHeight = Math.max(maxLineHeight, getDecoratedMeasurementVertical(lastVisibleChild));
                for (int i = getChildCount() - 2; i >= 0; i--) {
                    final View child = getChildAt(i);
                    if (getDecoratedTop(child) == topOffset) {
                        maxLineHeight = Math.max(maxLineHeight, getDecoratedMeasurementVertical(child));
                    } else {
                        break;
                    }
                }
            }

            // 布局新的 item view
            for (int i = minPosition; i < getItemCount(); i++) {

                // 獲取item view, 添加、測量、獲取尺寸
                final View itemView = recycler.getViewForPosition(i);
                addView(itemView);
                measureChildWithMargins(itemView, 0, 0);

                final int sizeHorizontal = getDecoratedMeasurementHorizontal(itemView);
                final int sizeVertical = getDecoratedMeasurementVertical(itemView);
                // 進行布局
                if (leftOffset + sizeHorizontal <= getHorizontalSpace()) {
                    // 如果這行能夠布局,則往后排
                    // layout
                    layoutDecoratedWithMargins(itemView, leftOffset, topOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical);
                    final Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical + mVerticalOffset);
                    // 保存布局信息
                    mItemRects.put(i, rect);

                    // 修正橫向計算偏移量
                    leftOffset += sizeHorizontal;
                    maxLineHeight = Math.max(maxLineHeight, sizeVertical);
                } else {
                    // 如果當前行不夠,則往下一行挪
                    // 修正計算偏移量、行高
                    topOffset += maxLineHeight;
                    maxLineHeight = 0;
                    leftOffset = getPaddingLeft();

                    // layout
                    if (topOffset - dy > getHeight() - getPaddingBottom()) {
                        // 如果超出下邊界
                        // 移除并回收該item view
                        removeAndRecycleView(itemView, recycler);
                        break;
                    } else {
                        // 如果沒有超出下邊界,則繼續布局
                        layoutDecoratedWithMargins(itemView, leftOffset, topOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical);
                        final Rect rect = new Rect(leftOffset, topOffset + mVerticalOffset, leftOffset + sizeHorizontal, topOffset + sizeVertical + mVerticalOffset);
                        // 保存布局信息
                        mItemRects.put(i, rect);
                        // 修正計算偏移量、行高
                        leftOffset += sizeHorizontal;
                        maxLineHeight = Math.max(maxLineHeight, sizeVertical);
                    }
                }
            }
        } else {
            // 手指下滑,逆序布局新的child
            int maxPos = getItemCount() - 1;
            if (getChildCount() > 0) {
                maxPos = getPosition(getChildAt(0)) - 1;
            }

            for (int i = maxPos; i >= 0; i--) {
                Rect rect = mItemRects.get(i);
                // 判斷底部是否在上邊界下面
                if (rect.bottom - mVerticalOffset - dy >= getPaddingTop()) {
                    // 獲取item view, 添加、設置尺寸、布局
                    final View itemView = recycler.getViewForPosition(i);
                    addView(itemView, 0);
                    measureChildWithMargins(itemView, 0, 0);
                    layoutDecoratedWithMargins(itemView, rect.left, rect.top - mVerticalOffset, rect.right, rect.bottom - mVerticalOffset);
                }
            }
        }

        return dy;
    }

    /* 對數據改變時的一些修正 */

    @Override
    public void onItemsChanged(RecyclerView recyclerView) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    @Override
    public void onItemsAdded(RecyclerView recyclerView, int positionStart, int itemCount) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    @Override
    public void onItemsMoved(RecyclerView recyclerView, int from, int to, int itemCount) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    @Override
    public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int itemCount) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    @Override
    public void onItemsUpdated(RecyclerView recyclerView, int positionStart, int itemCount) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    @Override
    public void onItemsUpdated(RecyclerView recyclerView, int positionStart, int itemCount, Object payload) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    @Override
    public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) {
        mVerticalOffset = 0;
        mItemRects.clear();
    }

    /**
     * 獲取 child view 橫向上需要占用的空間,margin計算在內
     *
     * @param view item view
     * @return child view 橫向占用的空間
     */
    private int getDecoratedMeasurementHorizontal(View view) {
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                view.getLayoutParams();
        return getDecoratedMeasuredWidth(view) + params.leftMargin
                + params.rightMargin;
    }

    /**
     * 獲取 child view 縱向上需要占用的空間,margin計算在內
     *
     * @param view item view
     * @return child view 縱向占用的空間
     */
    private int getDecoratedMeasurementVertical(View view) {
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
        return getDecoratedMeasuredHeight(view) + params.topMargin + params.bottomMargin;
    }

    /**
     * @return 橫向的可布局的空間
     */
    private int getHorizontalSpace() {
        return getWidth() - getPaddingLeft() - getPaddingRight();
    }
}

源代碼地址:FlowLayoutManager

參考文章

打造屬于你的LayoutManager

【Android】掌握自定義LayoutManager(一) 系列開篇 常見誤區、問題、注意事項,常用API。

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,422評論 25 708
  • 簡介: 提供一個讓有限的窗口變成一個大數據集的靈活視圖。 術語表: Adapter:RecyclerView的子類...
    酷泡泡閱讀 5,217評論 0 16
  • 這篇文章分三個部分,簡單跟大家講一下 RecyclerView 的常用方法與奇葩用法;工作原理與ListView比...
    LucasAdam閱讀 4,414評論 0 27
  • ///是文檔注釋,只能寫在類、方法、屬性的前面。不能用來注釋單個變量。 Console.Write();直接打印 ...
    向著遠方奔跑閱讀 145評論 0 0
  • 差不多在今年三、四月的時候同時用了純銀運營的“產品犬舍”和韓叔的“運營狗工作日記” 這兩個產品的owner分別是產...
    charmzyn閱讀 802評論 0 0