RecyclerView 滚动定位至目标 Item 并对齐顶部

关于使用LinearLayoutManager的RecyclerView需要滚动定位到指定Item,基于之前对ListView的使用,我们很容易就从RecyclerView中找到这样的方法。

scrollToPosition(int position)

smoothScrollToPosition(int position)

但是使用过的同学都会发现它存在着这么一个问题:当你要定位的Item已经完全显示时,RecyclerView并不会产生滚动行为;当你要定位的Item并不完全显示或在显示区域以外时,RecyclerView虽然会产生滚动行为,但是只要Item完全显示后滚动行为就会停止,即Item有可能会在RecyclerView显示区域的顶部,也有可能在显示区域的底部。

这样一来,面对“需要滚动定位到Item时,Item对齐到顶部”的需求,就很尴尬了。 😯

这尴尬事刚好我前几天就碰上了,在网上一搜发现很多文章介绍的方法都是想方设法地计算目标Item离当前位置的距离,用上了scrollBy(int x, int y)smoothScrollBy(int dx, int dy),还要监听下滚动事件作最后的修正处理什么的。

我也确实按这些个方法试了下,最终效果不佳,尤其是我的每一个Item的高度是不固定的,甚至会出现Item高度大于RecyclerView高度的情况,也就是RecyclerView的显示区不能完全展示这个Item。所以还是自己动手丰衣足食啊~~ :mrgreen: Demo扔在最后~~


非平滑滚动(无动画)

先顺着RecyclerView.scrollToPosition(int)看看。

/**
 * Convenience method to scroll to a certain position.
 *
 * RecyclerView does not implement scrolling logic, rather forwards the call to
 * {@link android.support.v7.widget.RecyclerView.LayoutManager#scrollToPosition(int)}
 * @param position Scroll to this adapter position
 * @see android.support.v7.widget.RecyclerView.LayoutManager#scrollToPosition(int)
 */
public void scrollToPosition(int position) {
    if (mLayoutFrozen) {
        return;
    }
    stopScroll();
    if (mLayout == null) {
        Log.e(TAG, "Cannot scroll to position a LayoutManager set. "
                + "Call setLayoutManager with a non-null argument.");
        return;
    }
    mLayout.scrollToPosition(position);
    awakenScrollBars();
}

其实RecyclerView本身是不实现滚动逻辑的,这些工作都是交由LayoutManager来处理。因为LayoutManager的scrollToPosition(int)里没有任何处理,所以再去看看LinearLayoutManager的scrollToPosition(int)

/**
 * Scroll the RecyclerView to make the position visible.
 *
 * RecyclerView will scroll the minimum amount that is necessary to make the
 * target position visible. If you are looking for a similar behavior to
 * {@link android.widget.ListView#setSelection(int)} or
 * {@link android.widget.ListView#setSelectionFromTop(int, int)}, use
 * {@link #scrollToPositionWithOffset(int, int)}.
 *
 * Note that scroll position change will not be reflected until the next layout call.
 *
 * @param position Scroll to this adapter position
 * @see #scrollToPositionWithOffset(int, int)
 */
@Override
public void scrollToPosition(int position) {
    mPendingScrollPosition = position;
    mPendingScrollPositionOffset = INVALID_OFFSET;
    if (mPendingSavedState != null) {
        mPendingSavedState.invalidateAnchor();
    }
    requestLayout();
}

有发现!!! 😎

注释中提到了scrollToPosition这个方法只是移动最小距离以使得Item能完全展示出来,如果我们想要寻找类似于ListView的setSelection(int)setSelectionFromTop(int, int)这样的方法,我们应该使用scrollToPositionWithOffset(int, int)这个方法。

/**
 * Scroll to the specified adapter position with the given offset from resolved layout
 * start. Resolved layout start depends on {@link #getReverseLayout()},
 * {@link ViewCompat#getLayoutDirection(android.view.View)} and {@link #getStackFromEnd()}.
 * 
 * For example, if layout is {@link #VERTICAL} and {@link #getStackFromEnd()} is true, calling
 * scrollToPositionWithOffset(10, 20) will layout such that
 * item[10]'s bottom is 20 pixels above the RecyclerView's bottom.
 * 
 * Note that scroll position change will not be reflected until the next layout call.
 * 
 * If you are just trying to make a position visible, use {@link #scrollToPosition(int)}.
 *
 * @param position Index (starting at 0) of the reference item.
 * @param offset   The distance (in pixels) between the start edge of the item view and
 *                 start edge of the RecyclerView.
 * @see #setReverseLayout(boolean)
 * @see #scrollToPosition(int)
 */
public void scrollToPositionWithOffset(int position, int offset) {
    mPendingScrollPosition = position;
    mPendingScrollPositionOffset = offset;
    if (mPendingSavedState != null) {
        mPendingSavedState.invalidateAnchor();
    }
    requestLayout();
}

这下子事情好办了,我们把原来使用RecyclerView.scrollToPosition(int)的地方按照RecyclerView.scrollToPosition(int)的处理方式来使用LinearLayoutManager的scrollToPositionWithOffset(int, int)。如下:

// recyclerView.scrollToPosition(targetPosition);
recyclerView.stopScroll();
((LinearLayoutManager)recyclerView.getLayoutManager()).scrollToPositionWithOffset(targetPosition, 0);

这就实现了非平滑滚动,直接把目标Item定位到RecyclerView顶部了。


平滑滚动

关于平滑滚动,我们顺着RecyclerView.smoothScrollToPosition(int)去看。

/**
 * Starts a smooth scroll to an adapter position.
 * 
 * To support smooth scrolling, you must override
 * {@link LayoutManager#smoothScrollToPosition(RecyclerView, State, int)} and create a
 * {@link SmoothScroller}.
 * 
 * {@link LayoutManager} is responsible for creating the actual scroll action. If you want to
 * provide a custom smooth scroll logic, override
 * {@link LayoutManager#smoothScrollToPosition(RecyclerView, State, int)} in your
 * LayoutManager.
 *
 * @param position The adapter position to scroll to
 * @see LayoutManager#smoothScrollToPosition(RecyclerView, State, int)
 */
public void smoothScrollToPosition(int position) {
    if (mLayoutFrozen) {
        return;
    }
    if (mLayout == null) {
        Log.e(TAG, "Cannot smooth scroll without a LayoutManager set. "
                + "Call setLayoutManager with a non-null argument.");
        return;
    }
    mLayout.smoothScrollToPosition(this, mState, position);
}

果然,这些工作RecyclerView就是不用干,就是交由LayoutManager来处理。 😛

而LayoutManager的smoothScrollToPosition(RecyclerView, RecyclerView.State, int)里也是没有任何处理的,我们直接去看LinearLayoutManager的smoothScrollToPosition(RecyclerView, RecyclerView.State, int)

@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
        int position) {
    LinearSmoothScroller linearSmoothScroller =
            new LinearSmoothScroller(recyclerView.getContext());
    linearSmoothScroller.setTargetPosition(position);
    startSmoothScroll(linearSmoothScroller);
}

其实LinearLayoutManager是使用了一个LinearSmoothScroller来完成平滑滚动,那看来我们要找的东西就在这个LinearSmoothScroller里,我们来看看LinearSmoothScroller这个类。

/**
 * {@link RecyclerView.SmoothScroller} implementation which uses a {@link LinearInterpolator} until
 * the target position becomes a child of the RecyclerView and then uses a
 * {@link DecelerateInterpolator} to slowly approach to target position.
 * 
 * If the {@link RecyclerView.LayoutManager} you are using does not implement the
 * {@link RecyclerView.SmoothScroller.ScrollVectorProvider} interface, then you must override the
 * {@link #computeScrollVectorForPosition(int)} method. All the LayoutManagers bundled with
 * the support library implement this interface.
 */
public class LinearSmoothScroller extends RecyclerView.SmoothScroller {

    private static final String TAG = "LinearSmoothScroller";

    private static final boolean DEBUG = false;

    private static final float MILLISECONDS_PER_INCH = 25f;

    private static final int TARGET_SEEK_SCROLL_DISTANCE_PX = 10000;

    /**
     * Align child view's left or top with parent view's left or top
     *
     * @see #calculateDtToFit(int, int, int, int, int)
     * @see #calculateDxToMakeVisible(android.view.View, int)
     * @see #calculateDyToMakeVisible(android.view.View, int)
     */
    public static final int SNAP_TO_START = -1;

    /**
     * Align child view's right or bottom with parent view's right or bottom
     *
     * @see #calculateDtToFit(int, int, int, int, int)
     * @see #calculateDxToMakeVisible(android.view.View, int)
     * @see #calculateDyToMakeVisible(android.view.View, int)
     */
    public static final int SNAP_TO_END = 1;

    /**
     * Decides if the child should be snapped from start or end, depending on where it
     * currently is in relation to its parent.
     * For instance, if the view is virtually on the left of RecyclerView, using
     * {@code SNAP_TO_ANY} is the same as using {@code SNAP_TO_START}
     *
     * @see #calculateDtToFit(int, int, int, int, int)
     * @see #calculateDxToMakeVisible(android.view.View, int)
     * @see #calculateDyToMakeVisible(android.view.View, int)
     */
    public static final int SNAP_TO_ANY = 0;

    ...

}

没看几行马上就又有发现了!!! 😎

LinearSmoothScroller继承于SmoothScroller,而SmoothScroller就是一个为平滑滚动(smooth scrolling)而设计的基础类。在LinearSmoothScroller里,我们发现了SNAP_TO_START、SNAP_TO_END、SNAP_TO_ANY,注释对它们的说明就是用来标记子View和父View的对齐方式的,其中SNAP_TO_START的注释说明就明显符合我们的需求,我们看看在哪里使用上了?

/**
 * When scrolling towards a child view, this method defines whether we should align the left
 * or the right edge of the child with the parent RecyclerView.
 *
 * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
 * @see #SNAP_TO_START
 * @see #SNAP_TO_END
 * @see #SNAP_TO_ANY
 */
protected int getHorizontalSnapPreference() {
    return mTargetVector == null || mTargetVector.x == 0 ? SNAP_TO_ANY :
            mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START;
}

/**
 * When scrolling towards a child view, this method defines whether we should align the top
 * or the bottom edge of the child with the parent RecyclerView.
 *
 * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
 * @see #SNAP_TO_START
 * @see #SNAP_TO_END
 * @see #SNAP_TO_ANY
 */
protected int getVerticalSnapPreference() {
    return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY :
            mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START;
}

就是这两个方法的返回值,在LinearSmoothScroller的onTargetFound中决定了滑动距离的计算结果~~

@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
    final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
    final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
    final int distance = (int) Math.sqrt(dx * dx + dy * dy);
    final int time = calculateTimeForDeceleration(distance);
    if (time > 0) {
        action.update(-dx, -dy, time, mDecelerateInterpolator);
    }
}

这下子,我们就可以尝试来解决一下了,解决的方法就是我们让getHorizontalSnapPreference()getVerticalSnapPreference()的返回值固定为SNAP_TO_START,也就是要对齐到RecyclerView显示区的起始位置。

所以我们重写LinearLayoutManager的smoothScrollToPosition(RecyclerView, RecyclerView.State, int)和LinearSmoothScroller的getHorizontalSnapPreference()getVerticalSnapPreference()

public class TopSnapLayoutManager extends LinearLayoutManager {

    public TopSnapLayoutManager(Context context) {
        super(context);
    }

    public TopSnapLayoutManager(Context context, int orientation, boolean reverseLayout) {
        super(context, orientation, reverseLayout);
    }

    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
        TopSnapSmoothScroller testSmoothScroller =
            new TopSnapSmoothScroller(recyclerView.getContext());
        testSmoothScroller.setTargetPosition(position);
        startSmoothScroll(testSmoothScroller);
    }

    public class TopSnapSmoothScroller extends LinearSmoothScroller {

        public TopSnapSmoothScroller(Context context) {
            super(context);
        }

        /**
         * When scrolling towards a child view, this method defines we should align
         * child view's left with the parent RecyclerView's left.
         *
         * @return SNAP_TO_START
         * @see #SNAP_TO_START
         * @see #SNAP_TO_END
         * @see #SNAP_TO_ANY
         */
        @Override
        protected int getHorizontalSnapPreference() {
            return SNAP_TO_START;
        }

        /**
         * When scrolling towards a child view, this method defines we should align
         * child view's top with the parent RecyclerView's top.
         *
         * @return SNAP_TO_START
         * @see #SNAP_TO_START
         * @see #SNAP_TO_END
         * @see #SNAP_TO_ANY
         */
        @Override
        protected int getVerticalSnapPreference() {
            return SNAP_TO_START;
        }

    }

}

至此,RecyclerView滚动定位至目标Item并对齐顶部的需求就实现啦~~~ 😀

Demo

TopSnapRecyclerViewDemo

“RecyclerView 滚动定位至目标 Item 并对齐顶部”的2个回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注