SnapHelper实现RecyclerView的滑动对齐

在实际项目中可能有这样的需求,在滑动后需要对子项对齐也或者是类似ViewPager的每次只滑动一项。
SnapHelper是 androidx 中的对RecyclerView的功能拓展。能很简单的实现该功能

  • LinearSnapHelper 水平和垂直滑动多页居中对齐
  • PagerSnapHelper 水平和垂直滑动单页居中对齐

一、使用

LinearSnapHelper().attachToRecyclerView(recyclerView)

二、原理

RecyclerView功能已经非常强大,支持监听内部各种状态。
SnapHelper是通过设置OnScrollListenerOnFlingListener来实现RecyclerView 抛的动作和滚动监听。

1. OnScrollListener

监听RecyclerView的滚动开始、抛、结束,以及滚动偏移量。

一个RecyclerView可对应多个OnScrollListener

/**
     * An OnScrollListener can be added to a RecyclerView to receive messages when a scrolling event
     * has occurred on that RecyclerView.
     * 

* @see RecyclerView#addOnScrollListener(OnScrollListener) * @see RecyclerView#clearOnChildAttachStateChangeListeners() * */ public abstract static class OnScrollListener { /** * Callback method to be invoked when RecyclerView's scroll state changes. * * @param recyclerView The RecyclerView whose scroll state has changed. * @param newState The updated scroll state. One of {@link #SCROLL_STATE_IDLE}, * {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}. */ public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState){} /** * Callback method to be invoked when the RecyclerView has been scrolled. This will be * called after the scroll has completed. *

* This callback will also be called if visible item range changes after a layout * calculation. In that case, dx and dy will be 0. * * @param recyclerView The RecyclerView which scrolled. * @param dx The amount of horizontal scroll. * @param dy The amount of vertical scroll. */ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){} }

2.OnFlingListener

监听RecyclerView抛动作的速度

一个RecyclerView只能对应一个OnFlingListener

/**
     * This class defines the behavior of fling if the developer wishes to handle it.
     * 

* Subclasses of {@link OnFlingListener} can be used to implement custom fling behavior. * * @see #setOnFlingListener(OnFlingListener) */ public abstract static class OnFlingListener { /** * Override this to handle a fling given the velocities in both x and y directions. * Note that this method will only be called if the associated {@link LayoutManager} * supports scrolling and the fling is not handled by nested scrolls first. * * @param velocityX the fling velocity on the X axis * @param velocityY the fling velocity on the Y axis * * @return true if the fling was handled, false otherwise. */ public abstract boolean onFling(int velocityX, int velocityY); }

3. attachToRecyclerView

通过addOnScrollListener()setOnFlingListener(), 并且实例化了Scroller用于滚动计算。

 /**
     * Attaches the {@link SnapHelper} to the provided RecyclerView, by calling
     * {@link RecyclerView#setOnFlingListener(RecyclerView.OnFlingListener)}.
     * You can call this method with {@code null} to detach it from the current RecyclerView.
     *
     * @param recyclerView The RecyclerView instance to which you want to add this helper or
     *                     {@code null} if you want to remove SnapHelper from the current
     *                     RecyclerView.
     *
     * @throws IllegalArgumentException if there is already a {@link RecyclerView.OnFlingListener}
     * attached to the provided {@link RecyclerView}.
     *
     */
    public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
            throws IllegalStateException {
        if (mRecyclerView == recyclerView) {
            return; // nothing to do
        }
        if (mRecyclerView != null) {
            destroyCallbacks();
        }
        mRecyclerView = recyclerView;
        if (mRecyclerView != null) {
            setupCallbacks();
            mGravityScroller = new Scroller(mRecyclerView.getContext(),
                    new DecelerateInterpolator());
            snapToTargetExistingView();
        }
    }
   private void setupCallbacks() throws IllegalStateException {
        if (mRecyclerView.getOnFlingListener() != null) {
            throw new IllegalStateException("An instance of OnFlingListener already set.");
        }
        mRecyclerView.addOnScrollListener(mScrollListener);
        mRecyclerView.setOnFlingListener(this);
    }

唯一用到Scroller的方法是根据速率计算距离,返回最终的finalX,finalY坐标

/**
     * Calculated the estimated scroll distance in each direction given velocities on both axes.
     *
     * @param velocityX     Fling velocity on the horizontal axis.
     * @param velocityY     Fling velocity on the vertical axis.
     *
     * @return array holding the calculated distances in x and y directions
     * respectively.
     */
    public int[] calculateScrollDistance(int velocityX, int velocityY) {
        int[] outDist = new int[2];
        mGravityScroller.fling(0, 0, velocityX, velocityY,
                Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        outDist[0] = mGravityScroller.getFinalX();
        outDist[1] = mGravityScroller.getFinalY();
        return outDist;
    }

现在看一下监听器回调,SnapHelper做了哪里处理。

onFling(OnFlingListener)

  public boolean onFling(int velocityX, int velocityY) {
        RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
        if (layoutManager == null) {
            return false;
        }
        RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
        if (adapter == null) {
            return false;
        }
        int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
        return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
                && snapFromFling(layoutManager, velocityX, velocityY);
    }

如果速率低于最小抛速率时,返回false, 否则进行snapFromFling处理

最小抛速率和最大抛速率都是由ViewConfiguration提供。

mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); 
 /**
     * Helper method to facilitate for snapping triggered by a fling.
     *
     * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
     *                      {@link RecyclerView}.
     * @param velocityX     Fling velocity on the horizontal axis.
     * @param velocityY     Fling velocity on the vertical axis.
     *
     * @return true if it is handled, false otherwise.
     */
    private boolean snapFromFling(@NonNull RecyclerView.LayoutManager layoutManager, int velocityX,
            int velocityY) {
        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
            return false;
        }

        RecyclerView.SmoothScroller smoothScroller = createScroller(layoutManager);
        if (smoothScroller == null) {
            return false;
        }

        int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
        if (targetPosition == RecyclerView.NO_POSITION) {
            return false;
        }

        smoothScroller.setTargetPosition(targetPosition);
        layoutManager.startSmoothScroll(smoothScroller);
        return true;
    }

snapFromFling主要是3个逻辑

  • 滚动: SmoothScroller负责执行滚动动作
  • 查找目标位置: findTargetSnapPosition由子类实现
  • 对齐:calculateDistanceToFinalSnap由子类实现

    createScroller 创建的Scroller重写了onTargetFound方法

SmoothScroller
smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);

平滑滚动到RecyclerView
LinearLayoutManagersmoothScrollToPosition也是类似的实现。这个滚动只会让相应的Item显示在屏幕上,不保证对齐。

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

查找SnapView需要滚动到的位置,NO_POSITION表示查找失败
LinearSnapHelper的实现中,findTargetSnapPosition 核心函数就是estimateNextPositionDiffForFling

 /**
     * Estimates a position to which SnapHelper will try to scroll to in response to a fling.
     *
     * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
     *                      {@link RecyclerView}.
     * @param helper        The {@link OrientationHelper} that is created from the LayoutManager.
     * @param velocityX     The velocity on the x axis.
     * @param velocityY     The velocity on the y axis.
     *
     * @return The diff between the target scroll position and the current position.
     */
    private int estimateNextPositionDiffForFling(RecyclerView.LayoutManager layoutManager,
            OrientationHelper helper, int velocityX, int velocityY) {
        int[] distances = calculateScrollDistance(velocityX, velocityY);
        float distancePerChild = computeDistancePerChild(layoutManager, helper);
        if (distancePerChild <= 0) {
            return 0;
        }
        int distance =
                Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
        return (int) Math.round(distance / distancePerChild);
    }

calculateScrollDistance 计算速率需要滚动的距离
computeDistancePerChild根据当前布局显示的View总距离 / 布局个数,算出平均每个View占用多少像素。 1f * distance / ((maxPos - minPos) + 1)
return (int) Math.round(distance / distancePerChild) 用滚动距离 / View的距离 算出DeltaJump

int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
    targetPos = 0;
}
if (targetPos >= itemCount) {
    targetPos = itemCount - 1;
}
return targetPos;

最后通过currentPosition + deltaJump就是targetSnapPositon

calculateDistanceToFinalSnap

snapFromFlingcreateScroller(layoutManager)用于滚动,而Scorller设置了回调函数onTargetFound

protected LinearSmoothScroller createSnapScroller(RecyclerView.LayoutManager layoutManager) {
    if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
        return null;
    }
    return new LinearSmoothScroller(mRecyclerView.getContext()) {
        @Override
        protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
            if (mRecyclerView == null) {
                // The associated RecyclerView has been removed so there is no action to take.
                return;
            }
            int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
                    targetView);
            final int dx = snapDistances[0];
            final int dy = snapDistances[1];
            final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
            if (time > 0) {
                action.update(dx, dy, time, mDecelerateInterpolator);
            }
        }

        @Override
        protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
            return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
        }
    };
}

calculateDistanceToFinalSnap(layoutManager, targetView)计算 targetView 到指定位置需要滚动的距离(核心函数
calculateTimeForDeceleration(dx) 计算滚动的距离需要的时间
action.update(dx, dy, time, mDecelerateInterpolator)使用的是减速的插值器
在LinearSnapHelper的实现中,calculateDistanceToFinalSnap计算的离中间的距离distanceToCenter,所以LinearSnapHelper实现的是居中对齐效果

public int[] calculateDistanceToFinalSnap(
        @NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
    int[] out = new int[2];
    if (layoutManager.canScrollHorizontally()) {
        out[0] = distanceToCenter(layoutManager, targetView,
                getHorizontalHelper(layoutManager));
    } else {
        out[0] = 0;
    }

    if (layoutManager.canScrollVertically()) {
        out[1] = distanceToCenter(layoutManager, targetView,
                getVerticalHelper(layoutManager));
    } else {
        out[1] = 0;
    }
    return out;
}

distanceToCenter计算targetView的中点距离容器中点的距离。

private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,
        @NonNull View targetView, OrientationHelper helper) {
    final int childCenter = helper.getDecoratedStart(targetView)
            + (helper.getDecoratedMeasurement(targetView) / 2);
    final int containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
    return childCenter - containerCenter;
}

OnScroll

onFling只有再抛动作的时候才会触发的,缓慢滑动是不会回调该方法,受最小抛速率限制。
所以OnScroll是事件处理的一个补充,只需要完成对齐的逻辑即可(不需要做惯性滚动处理)

private final RecyclerView.OnScrollListener mScrollListener =
    new RecyclerView.OnScrollListener() {
        boolean mScrolled = false;

        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            super.onScrollStateChanged(recyclerView, newState);
            if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
                mScrolled = false;
                snapToTargetExistingView();
            }
        }

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            if (dx != 0 || dy != 0) {
                mScrolled = true;
            }
        }
    };

onScrollStateChanged在滚动停止后,执行了snapToTargetExistingView方法,逻辑类似于onTargetFound的回调

/**
 * Snaps to a target view which currently exists in the attached {@link RecyclerView}. This
 * method is used to snap the view when the {@link RecyclerView} is first attached; when
 * snapping was triggered by a scroll and when the fling is at its final stages.
 */
void snapToTargetExistingView() {
    if (mRecyclerView == null) {
        return;
    }
    RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
    if (layoutManager == null) {
        return;
    }
    View snapView = findSnapView(layoutManager);
    if (snapView == null) {
        return;
    }
    int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
    if (snapDistance[0] != 0 || snapDistance[1] != 0) {
        mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
    }
}
  • 为什么不在onScroll处理抛动作?
    没有提供速率参数,不够方便。

三、总结

1. OnFlingListener
  • 滚动
  • 查找目标位置
  • 对齐
2. OnScrollListener
  • 对齐
3. SnapHelper自定义
 /**
     * Override this method to snap to a particular point within the target view or the container
     * view on any axis.
     * 

* This method is called when the {@link SnapHelper} has intercepted a fling and it needs * to know the exact distance required to scroll by in order to snap to the target view. * * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached * {@link RecyclerView} * @param targetView the target view that is chosen as the view to snap * * @return the output coordinates the put the result into. out[0] is the distance * on horizontal axis and out[1] is the distance on vertical axis. */ @SuppressWarnings("WeakerAccess") @Nullable public abstract int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView); /** * Override this method to provide a particular target view for snapping. *

* This method is called when the {@link SnapHelper} is ready to start snapping and requires * a target view to snap to. It will be explicitly called when the scroll state becomes idle * after a scroll. It will also be called when the {@link SnapHelper} is preparing to snap * after a fling and requires a reference view from the current set of child views. *

* If this method returns {@code null}, SnapHelper will not snap to any view. * * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached * {@link RecyclerView} * * @return the target view to which to snap on fling or end of scroll */ @SuppressWarnings("WeakerAccess") @Nullable public abstract View findSnapView(RecyclerView.LayoutManager layoutManager); /** * Override to provide a particular adapter target position for snapping. * * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached * {@link RecyclerView} * @param velocityX fling velocity on the horizontal axis * @param velocityY fling velocity on the vertical axis * * @return the target adapter position to you want to snap or {@link RecyclerView#NO_POSITION} * if no snapping should happen */ public abstract int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY);

  • calculateDistanceToFinalSnap 根据SnapView做对齐处理
  • findSnapView 查找容器当前的SnapView
  • findTargetSnapPosition 根据速率计算需要滚动的位置

对于SnapHelper的子类只要重写以上3个方法就可以实现各种对齐的方式了。

LinearSnapHelper,实现的是居中对齐多页滚动的效果
  • calculateDistanceToFinalSnap 计算SnapView的中点距离容器中点的距离
  • findSnapView 遍历layout寻找居中的View
  • findTargetSnapPosition 多页滚动
PagerSnapHelper,实现的是居中对齐单页滚动的效果
  • findTargetSnapPosition 单页滚动,-1或者1

calculateDistanceToFinalSnapfindSnapViewLinearSnapHelper 的实现

如果要实现左对齐,需要重写calculateDistanceToFinalSnap方法,计算SnapView左点距离容器做点的距离;而重写findSnapView方法,遍历layout寻找左边的View。如果要实现单页或者多页则可以参考LinearSnapHelperPagerSnapHelper的实现进行调整。
LeftLinearSnapHelper实现左对齐多页滚动

class LeftLinearSnapHelper : LinearSnapHelper() {

    private var mVerticalHelper: OrientationHelper? = null
    private var mHorizontalHelper: OrientationHelper? = null

    override fun calculateDistanceToFinalSnap(layoutManager: LayoutManager, targetView: View): IntArray {
        val out = IntArray(2)
        if (layoutManager.canScrollHorizontally()) {
            out[0] = distanceToLeft(targetView, getHorizontalHelper(layoutManager))
        } else {
            out[0] = 0
        }

        if (layoutManager.canScrollVertically()) {
            out[1] = distanceToLeft(targetView, getVerticalHelper(layoutManager))
        } else {
            out[1] = 0
        }
        return out
    }

    override fun findSnapView(layoutManager: LayoutManager): View? {
        if (layoutManager.canScrollVertically()) {
            return findLeftView(layoutManager, getVerticalHelper(layoutManager))
        } else if (layoutManager.canScrollHorizontally()) {
            return findLeftView(layoutManager, getHorizontalHelper(layoutManager))
        }
        return null
    }

    private fun distanceToLeft(targetView: View, helper: OrientationHelper): Int {
        val childLeft = helper.getDecoratedStart(targetView)
        val containerLeft = helper.startAfterPadding
        return childLeft - containerLeft
    }

    private fun findLeftView(layoutManager: LayoutManager, helper: OrientationHelper): View? {
        val childCount = layoutManager.childCount
        if (childCount == 0) {
            return null
        }
        var closestChild: View? = null
        val left = helper.startAfterPadding
        var absClosest = Int.MAX_VALUE
        for (i in 0 until childCount) {
            val child = layoutManager.getChildAt(i)
            val childLeft = helper.getDecoratedStart(child)
            val absDistance = abs(childLeft - left)
            if (absDistance < absClosest) {
                absClosest = absDistance
                closestChild = child
            }
        }
        return closestChild
    }

    private fun getVerticalHelper(layoutManager: LayoutManager): OrientationHelper {
        if (mVerticalHelper == null || mVerticalHelper!!.layoutManager !== layoutManager) {
            mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager)
        }
        return mVerticalHelper!!
    }

    private fun getHorizontalHelper(layoutManager: LayoutManager): OrientationHelper {
        if (mHorizontalHelper == null || mHorizontalHelper!!.layoutManager !== layoutManager) {
            mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager)
        }
        return mHorizontalHelper!!
    }
}

你可能感兴趣的:(SnapHelper实现RecyclerView的滑动对齐)