在实际项目中可能有这样的需求,在滑动后需要对子项对齐也或者是类似ViewPager
的每次只滑动一项。
SnapHelper
是 androidx 中的对RecyclerView
的功能拓展。能很简单的实现该功能
-
LinearSnapHelper
水平和垂直滑动多页居中对齐 -
PagerSnapHelper
水平和垂直滑动单页居中对齐
一、使用
LinearSnapHelper().attachToRecyclerView(recyclerView)
二、原理
RecyclerView功能已经非常强大,支持监听内部各种状态。
SnapHelper
是通过设置OnScrollListener
和OnFlingListener
来实现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
LinearLayoutManager
的smoothScrollToPosition
也是类似的实现。这个滚动只会让相应的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
在snapFromFling
中createScroller(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
calculateDistanceToFinalSnap
和findSnapView
同LinearSnapHelper
的实现
如果要实现左对齐
,需要重写calculateDistanceToFinalSnap
方法,计算SnapView左点距离容器做点的距离;而重写findSnapView
方法,遍历layout寻找左边的View。如果要实现单页或者多页则可以参考LinearSnapHelper
和PagerSnapHelper
的实现进行调整。
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!!
}
}