创建 RecyclerView LayoutManager – Part 2

Android社区 收藏文章

创建 RecyclerView LayoutManager – Part 2

创建 a RecyclerView LayoutManager – Part 2

本文是这个系列中的 Part 2,这里是 Part 1Part 3 的链接。

上次我们讲了创建一个 RecyclerView LayoutManager 的核心步骤。接下来, 我们会介绍如何给普通基于适配器的 View 加入一些附加特性。

友情提醒:示例中的代码在这里 Github


Item Decorations 支持

RecyclerView 有一个很好的特性 RecyclerView.ItemDecoration,它可以给 子视图添加自定义样式,还可以在不修改子视图布局参数的情况下插入 布局属性(margins)。后者就是 LayoutManager 必须提供的约束子视图布局方式。


RecyclerPlayground 里有几个 decorator 用来介绍它们的实现方式。

LayoutManager 中提供了一些辅助方法操作 decorations ,不需要我们自己实现:

  • getDecoratedLeft()代替child.getLeft()获取子视图的 left 边缘。
  • getDecoratedTop()代替getTop()获取子视图的 top 边缘。
  • getDecoratedRight()代替getRight()获取子视图的 right 边缘。
  • getDecoratedBottom()代替getBottom()获取子视图的 bottom 边缘。
  • 使用 measureChild()measureChildWithMargins() 代替child.measure() 测量来自 Recycler 的新视图。
  • 使用layoutDecorated()代替 child.layout() 布局来自 Recycler 的新视图。
  • 使用 getDecoratedMeasuredWidth()getDecoratedMeasuredHeight() 代替 child.getMeasuredWidth()child.getMeasuredHeight()获取 子视图的测量数据。

只要你使用了正确的方法去获取视图的属性和测量数据,RecyclerView 会自己搞定细节部分的处理。


数据集改变

当使用 notifyDataSetChanged()触发 RecyclerView.Adapter 的更新操作时, LayoutManager 负责更新布局中的视图。这时,onLayoutChildren()会被再次调用。 实现这个功能需要我们调整代码,判断出当前状态是生成一个新的视图 还是 adapter 更新期间的视图改变。下面是FixedGridLayoutManager中的填充方法的完整实现:

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //We have nothing to show for an empty data set but clear any existing views
    if (getItemCount() == 0) {
        detachAndScrapAttachedViews(recycler);
        return;
    }

    //...on empty layout, update child size measurements
    if (getChildCount() == 0) {
        //Scrap measure one child
        View scrap = recycler.getViewForPosition(0);
        addView(scrap);
        measureChildWithMargins(scrap, 0, 0);

        /*
         * We make some assumptions in this code based on every child
         * view being the same size (i.e. a uniform grid). This allows
         * us to compute the following values up front because they
         * won't change.
         */
        mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
        mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);

        detachAndScrapView(scrap, recycler);
    }

    updateWindowSizing();

    int childLeft;
    int childTop;
    if (getChildCount() == 0) { //First or empty layout
        /*
         * Reset the visible and scroll positions
         */
        mFirstVisiblePosition = 0;
        childLeft = childTop = 0;
    } else if (getVisibleChildCount() > getItemCount()) {
        //Data set is too small to scroll fully, just reset position
        mFirstVisiblePosition = 0;
        childLeft = childTop = 0;
    } else { //Adapter data set changes
        /*
         * Keep the existing initial position, and save off
         * the current scrolled offset.
         */
        final View topChild = getChildAt(0);
        if (mForceClearOffsets) {
            childLeft = childTop = 0;
            mForceClearOffsets = false;
        } else {
            childLeft = getDecoratedLeft(topChild);
            childTop = getDecoratedTop(topChild);
        }

        /*
         * Adjust the visible position if out of bounds in the
         * new layout. This occurs when the new item count in an adapter
         * is much smaller than it was before, and you are scrolled to
         * a location where no items would exist.
         */
        int lastVisiblePosition = positionOfIndex(getVisibleChildCount() - 1);
        if (lastVisiblePosition >= getItemCount()) {
            lastVisiblePosition = (getItemCount() - 1);
            int lastColumn = mVisibleColumnCount - 1;
            int lastRow = mVisibleRowCount - 1;

            //Adjust to align the last position in the bottom-right
            mFirstVisiblePosition = Math.max(
                    lastVisiblePosition - lastColumn - (lastRow * getTotalColumnCount()), 0);

            childLeft = getHorizontalSpace() - (mDecoratedChildWidth * mVisibleColumnCount);
            childTop = getVerticalSpace() - (mDecoratedChildHeight * mVisibleRowCount);

            //Correct cases where shifting to the bottom-right overscrolls the top-left
            // This happens on data sets too small to scroll in a direction.
            if (getFirstVisibleRow() == 0) {
                childTop = Math.min(childTop, 0);
            }
            if (getFirstVisibleColumn() == 0) {
                childLeft = Math.min(childLeft, 0);
            }
        }
    }

    //Clear all attached views into the recycle bin
    detachAndScrapAttachedViews(recycler);

    //Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler);
}

我们根据有没有已经被 attach 的子视图来判断当前是一个新的布局还是一个更新操作。 如果是更新,我们根据第一个可见视图的 position(通过监测视图左上角是哪个子视图) 和当前 x/y 滑动的位移这些信息去执行新的 fillGrid(),同时保证左上角的 item 位置不变。


下面是一些需要特殊处理得情况:

  • 当新的数据集很小,不足以滑动时,布局会将左上角重置为 position 是 0 的item。
  • 如果新的数据集很小,保持当前位置会使滚动超出边界。 我们就应该调整第一个 item 的位置,以便和右下角对齐。

onAdapterChanged()

这个方法提供了另一个重置布局的场所,设置新的 adapter 会触发这个事件 (在这,setAdapter会被再次调用)。 这个阶段你可以安全的返回一个与之前 adapter 完全不同的视图。所以, 示例中我们移除了所有当前视图(并没有回收它们)。

@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) {
    //Completely scrap the existing layout
    removeAllViews();
}

移除视图会触发一个新的布局过程,当 onLayoutChildren() 被再次调用时, 我们的代码会执行创建新视图的布局过程,因为现在没有 attched 的子视图。


Scroll to Position

另一个重要的特性就是给 LayoutManager 添加滚动到特定位置的功能。 可以带有有动画效果,也可以没有,下面是对应的两个回调方法。

scrollToPosition()

当 layout 应该立即将所给位置设为第一个可见 item 时,调用 RecyclerView 的 scrollToPosition()。 在一个 vertical list 里,item 应该放在顶部;horizontal list 中,通常放在左边。在我们的 网格布局中,被选中的 item 应该放在视图的左上角。

@Override
public void scrollToPosition(int position) {
    if (position >= getItemCount()) {
        Log.e(TAG, "Cannot scroll to "+position+", item count is "+getItemCount());
        return;
    }

    //Ignore current scroll offset, snap to top-left
    mForceClearOffsets = true;
    //Set requested position as first visible
    mFirstVisiblePosition = position;
    //Trigger a new view layout
    requestLayout();
}

因为有一个良好的 onLayoutChildren() 实现,这里就可以简单的更新目标位置并触发一个 新的 fill 操作。


smoothScrollToPosition()

在带有动画的情况下,我们需要使用一些稍微不同的方法。 在这方法里我们需要创建一个 RecyclerView.SmoothScroller实例, 然后在方法返回前请求startSmoothScroll()启动动画。

RecyclerView.SmoothScroller 是提供 API 的抽象类,含有四个方法:


  • onStart():当滑动动画开始时被触发。
  • onStop():当滑动动画停止时被触发。
  • onSeekTargetStep():当 scroller 搜索目标 view 时被重复调用,这个方法负责读取提供的 dx/dy ,然后更新应该在这两个方向移动的距离。
    • 这个方法有一个RecyclerView.SmoothScroller.Action实例做参数。 通过向 action 的 update()方法传递新的 dx, dy, duration 和 Interpolator , 告诉 view 在下一个阶段应该执行怎样的动画。
    • NOTE: 如果动画耗时过长,框架会对你发出警告, 应该调整动画的步骤,尽量和框架标准的动画耗时相同。
  • onTargetFound():只在目标视图被 attach 后调用一次。 这是将目标视图要通过动画移动到准确位置最后的场所。
    • 在内部,当 view 被 attach 时使用 LayoutManager 的 findViewByPosition() 方法 查找对象。如果你的 LayoutManager 可以有效匹配 view 和 position , 可以覆写这个方法来优化性能。默认提供的实现是通过每次遍历所有子视图查找。

你可以自己实现一个 scroller 达到你想要的效果。不过这里我们只使用系统提供的 LinearSmoothScroller 就好了。只需实现一个方法computeScrollVectorForPosition(), 然后告诉 scroller 初始方向还有从当前位置滚动到目标位置的大概距离。


@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, final int position) {
    if (position >= getItemCount()) {
        Log.e(TAG, "Cannot scroll to "+position+", item count is "+getItemCount());
        return;
    }

    /*
     * LinearSmoothScroller's default behavior is to scroll the contents until
     * the child is fully visible. It will snap to the top-left or bottom-right
     * of the parent depending on whether the direction of travel was positive
     * or negative.
     */
    LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) {
        /*
         * LinearSmoothScroller, at a minimum, just need to know the vector
         * (x/y distance) to travel in order to get from the current positioning
         * to the target.
         */
        @Override
        public PointF computeScrollVectorForPosition(int targetPosition) {
            final int rowOffset = getGlobalRowOfPosition(targetPosition)
                    - getGlobalRowOfPosition(mFirstVisiblePosition);
            final int columnOffset = getGlobalColumnOfPosition(targetPosition)
                    - getGlobalColumnOfPosition(mFirstVisiblePosition);

            return new PointF(columnOffset * mDecoratedChildWidth, rowOffset * mDecoratedChildHeight);
        }
    };
    scroller.setTargetPosition(position);
    startSmoothScroll(scroller);
}

在这个实现中,和现有 ListView 的行为相似,无论是 RecyclerView 的哪个方向滚动, 当视图完全可见时滚动就会停止。


接下来?

我们现在已经有些起色了!事实上还有很多可以实现的功能。在下篇文章中,我们会介绍 如何给你的 LayoutManager 提供数据集改变时的动画效果。


相关标签

扫一扫

在手机上阅读