簡介
RecyclerView在24.2.0版本中新增了SnapHelper這個輔助類颠焦,用于輔助RecyclerView在滾動結束時將Item對齊到某個位置何址。特別是列表橫向滑動時里逆,很多時候不會讓列表滑到任意位置,而是會有一定的規(guī)則限制用爪,這時候就可以通過SnapHelper來定義對齊規(guī)則了原押。
SnapHelper是一個抽象類,官方提供了一個LinearSnapHelper的子類偎血,可以讓RecyclerView滾動停止時相應的Item停留中間位置诸衔。25.1.0版本中官方又提供了一個PagerSnapHelper的子類盯漂,可以使RecyclerView像ViewPager一樣的效果,一次只能滑一頁笨农,而且居中顯示就缆。
這兩個子類使用方式也很簡單,只需要創(chuàng)建對象之后調用attachToRecyclerView()
附著到對應的RecyclerView對象上就可以了谒亦。
new LinearSnapHelper().attachToRecyclerView(mRecyclerView);
//或者
new PagerSnapHelper().attachToRecyclerView(mRecyclerView);
原理剖析
Fling操作
首先來了解一個概念竭宰,手指在屏幕上滑動RecyclerView然后松手,RecyclerView中的內容會順著慣性繼續(xù)往手指滑動的方向繼續(xù)滾動直到停止份招,這個過程叫做Fling切揭。Fling操作從手指離開屏幕瞬間被觸發(fā),在滾動停止時結束锁摔。
三個抽象方法
SnapHelper是一個抽象類廓旬,它有三個抽象方法:
-
public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY)
該方法會根據(jù)觸發(fā)Fling操作的速率(參數(shù)velocityX和參數(shù)velocityY)來找到RecyclerView需要滾動到哪個位置,該位置對應的ItemView就是那個需要進行對齊的列表項鄙漏。我們把這個位置稱為targetSnapPosition嗤谚,對應的View稱為targetSnapView棺蛛。如果找不到targetSnapPosition怔蚌,就返回RecyclerView.NO_POSITION。
-
public abstract View findSnapView(LayoutManager layoutManager)
該方法會找到當前l(fā)ayoutManager上最接近對齊位置的那個view旁赊,該view稱為SanpView桦踊,對應的position稱為SnapPosition。如果返回null终畅,就表示沒有需要對齊的View籍胯,也就不會做滾動對齊調整。
-
public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, @NonNull View targetView);
這個方法會計算第二個參數(shù)對應的ItemView當前的坐標與需要對齊的坐標之間的距離离福。該方法返回一個大小為2的int數(shù)組杖狼,分別對應x軸和y軸方向上的距離。
attachToRecyclerView()
現(xiàn)在來看attachToRecyclerView()
這個方法妖爷,SnapHelper正是通過該方法附著到RecyclerView上蝶涩,從而實現(xiàn)輔助RecyclerView滾動對齊操作。源碼如下:
public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
throws IllegalStateException {
//如果SnapHelper之前已經(jīng)附著到此RecyclerView上絮识,不用進行任何操作
if (mRecyclerView == recyclerView) {
return;
}
//如果SnapHelper之前附著的RecyclerView和現(xiàn)在的不一致绿聘,清理掉之前RecyclerView的回調
if (mRecyclerView != null) {
destroyCallbacks();
}
//更新RecyclerView對象引用
mRecyclerView = recyclerView;
if (mRecyclerView != null) {
//設置當前RecyclerView對象的回調
setupCallbacks();
//創(chuàng)建一個Scroller對象,用于輔助計算fling的總距離次舌,后面會涉及到
mGravityScroller = new Scroller(mRecyclerView.getContext(),
new DecelerateInterpolator());
//調用snapToTargetExistingView()方法以實現(xiàn)對SnapView的對齊滾動處理
snapToTargetExistingView();
}
}
可以看到熄攘,在attachToRecyclerView()
方法中會清掉SnapHelper之前保存的RecyclerView對象的回調(如果有的話),對新設置進來的RecyclerView對象設置回調,然后初始化一個Scroller對象,最后調用snapToTargetExistingView()
方法對SnapView進行對齊調整彼念。
snapToTargetExistingView()
該方法的作用是對SnapView進行滾動調整挪圾,以使得SnapView達到對齊效果浅萧。源碼如下:
void snapToTargetExistingView() {
if (mRecyclerView == null) {
return;
}
LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return;
}
//找出SnapView
View snapView = findSnapView(layoutManager);
if (snapView == null) {
return;
}
//計算出SnapView需要滾動的距離
int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
//如果需要滾動的距離不是為0,就調用smoothScrollBy()使RecyclerView滾動相應的距離
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}
可以看到哲思,snapToTargetExistingView()
方法就是先找到SnapView惯殊,然后計算SnapView當前坐標到目的坐標之間的距離,然后調用RecyclerView.smoothScrollBy()
方法實現(xiàn)對RecyclerView內容的平滑滾動也殖,從而將SnapView移到目標位置土思,達到對齊效果。RecyclerView.smoothScrollBy()
這個方法的實現(xiàn)原理這里就不展開了 忆嗜,它的作用就是根據(jù)參數(shù)平滑滾動RecyclerView的中的ItemView相應的距離己儒。
setupCallbacks()和destroyCallbacks()
再看下SnapHelper對RecyclerView設置了哪些回調:
private void setupCallbacks() throws IllegalStateException {
if (mRecyclerView.getOnFlingListener() != null) {
throw new IllegalStateException("An instance of OnFlingListener already set.");
}
mRecyclerView.addOnScrollListener(mScrollListener);
mRecyclerView.setOnFlingListener(this);
}
private void destroyCallbacks() {
mRecyclerView.removeOnScrollListener(mScrollListener);
mRecyclerView.setOnFlingListener(null);
}
可以看出RecyclerView設置的回調有兩個:一個是OnScrollListener對象mScrollListener.還有一個是OnFlingListener對象。由于SnapHelper實現(xiàn)了OnFlingListener接口,所以這個對象就是SnapHelper自身了.
先看下mScrollListener這個變量在怎樣實現(xiàn)的.
private final RecyclerView.OnScrollListener mScrollListener =
new RecyclerView.OnScrollListener() {
boolean mScrolled = false;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
//mScrolled為true表示之前進行過滾動.
//newState為SCROLL_STATE_IDLE狀態(tài)表示滾動結束停下來
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;
}
}
};
該滾動監(jiān)聽器的實現(xiàn)很簡單,只是在正常滾動停止的時候調用了snapToTargetExistingView()
方法對targetView進行滾動調整捆毫,以確保停止的位置是在對應的坐標上闪湾,這就是RecyclerView添加該OnScrollListener的目的。
除了OnScrollListener這個監(jiān)聽器绩卤,還對RecyclerView還設置了OnFlingListener這個監(jiān)聽器途样,而這個監(jiān)聽器就是SnapHelper自身。因為SnapHelper實現(xiàn)了RecyclerView.OnFlingListener接口濒憋。我們先來看看RecyclerView.OnFlingListener這個接口何暇。
public static abstract 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 washandled, false otherwise.
*/
public abstract boolean onFling(int velocityX, int velocityY);
}
這個接口中就只有一個onFling()
方法,該方法會在RecyclerView開始做fling操作時被調用凛驮。我們來看看SnapHelper怎么實現(xiàn)onFling()
方法:
@Override
public boolean onFling(int velocityX, int velocityY) {
LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return false;
}
RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
if (adapter == null) {
return false;
}
//獲取RecyclerView要進行fling操作需要的最小速率裆站,
//只有超過該速率,ItemView才會有足夠的動力在手指離開屏幕時繼續(xù)滾動下去
int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
//這里會調用snapFromFling()這個方法黔夭,就是通過該方法實現(xiàn)平滑滾動并使得在滾動停止時itemView對齊到目的坐標位置
return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
&& snapFromFling(layoutManager, velocityX, velocityY);
}
注釋解釋得很清楚宏胯。看下snapFromFling()
怎么操作的:
private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
int velocityY) {
//layoutManager必須實現(xiàn)ScrollVectorProvider接口才能繼續(xù)往下操作
if (!(layoutManager instanceof ScrollVectorProvider)) {
return false;
}
//創(chuàng)建SmoothScroller對象本姥,這個東西是一個平滑滾動器肩袍,用于對ItemView進行平滑滾動操作
RecyclerView.SmoothScroller smoothScroller = createSnapScroller(layoutManager);
if (smoothScroller == null) {
return false;
}
//通過findTargetSnapPosition()方法,以layoutManager和速率作為參數(shù)婚惫,找到targetSnapPosition
int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
if (targetPosition == RecyclerView.NO_POSITION) {
return false;
}
//通過setTargetPosition()方法設置滾動器的滾動目標位置
smoothScroller.setTargetPosition(targetPosition);
//利用layoutManager啟動平滑滾動器氛赐,開始滾動到目標位置
layoutManager.startSmoothScroll(smoothScroller);
return true;
}
可以看到,snapFromFling()
方法會先判斷l(xiāng)ayoutManager是否實現(xiàn)了ScrollVectorProvider接口辰妙,如果沒有實現(xiàn)該接口就不允許通過該方法做滾動操作鹰祸。那為啥一定要實現(xiàn)該接口呢?待會再來解釋密浑。接下來就去創(chuàng)建平滑滾動器SmoothScroller的一個實例蛙婴,layoutManager可以通過該平滑滾動器來進行滾動操作。SmoothScroller需要設置一個滾動的目標位置尔破,我們將通過findTargetSnapPosition()
方法來計算得到的targetSnapPosition給它街图,告訴滾動器要滾到這個位置浇衬,然后就啟動SmoothScroller進行滾動操作。
但是這里有一點需要注意一下餐济,默認情況下通過setTargetPosition()
方法設置的SmoothScroller只能將對應位置的ItemView滾動到與RecyclerView的邊界對齊耘擂,那怎么實現(xiàn)將該ItemView滾動到我們需要對齊的目標位置呢?就得對SmoothScroller進行一下處理了絮姆。
看下平滑滾動器RecyclerView.SmoothScroller醉冤,這個東西是通過createSnapScroller()
方法創(chuàng)建得到的:
@Nullable
protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
//同樣,這里也是先判斷l(xiāng)ayoutManager是否實現(xiàn)了ScrollVectorProvider這個接口篙悯,
//沒有實現(xiàn)該接口就不創(chuàng)建SmoothScroller
if (!(layoutManager instanceof ScrollVectorProvider)) {
return null;
}
//這里創(chuàng)建一個LinearSmoothScroller對象蚁阳,然后返回給調用函數(shù),
//也就是說鸽照,最終創(chuàng)建出來的平滑滾動器就是這個LinearSmoothScroller
return new LinearSmoothScroller(mRecyclerView.getContext()) {
//該方法會在targetSnapView被layout出來的時候調用螺捐。
//這個方法有三個參數(shù):
//第一個參數(shù)targetView,就是本文所講的targetSnapView
//第二個參數(shù)RecyclerView.State這里沒用到矮燎,先不管它
//第三個參數(shù)Action定血,這個是什么東西呢?它是SmoothScroller的一個靜態(tài)內部類,
//保存著SmoothScroller在平滑滾動過程中一些信息诞外,比如滾動時間澜沟,滾動距離,差值器等
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
//calculateDistanceToFinalSnap()方法上面解釋過浅乔,
//得到targetSnapView當前坐標到目的坐標之間的距離
int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
//通過calculateTimeForDeceleration()方法得到做減速滾動所需的時間
final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
//調用Action的update()方法倔喂,更新SmoothScroller的滾動速率铝条,使其減速滾動到停止
//這里的這樣做的效果是靖苇,此SmoothScroller用time這么長的時間以mDecelerateInterpolator這個差值器的滾動變化率滾動dx或者dy這么長的距離
action.update(dx, dy, time, mDecelerateInterpolator);
}
}
//該方法是計算滾動速率的,返回值代表滾動速率班缰,該值會影響剛剛上面提到的
//calculateTimeForDeceleration()的方法的返回返回值贤壁,
//MILLISECONDS_PER_INCH的值是100,也就是說該方法的返回值代表著每dpi的距離要滾動100毫秒
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}
};
}
通過以上的分析可以看到埠忘,createSnapScroller()
創(chuàng)建的是一個LinearSmoothScroller脾拆,并且在創(chuàng)建該LinearSmoothScroller的時候主要考慮兩個方面:
- 第一個是滾動速率,由
calculateSpeedPerPixel()
方法決定莹妒; - 第二個是在滾動過程中名船,targetView即將要進入到視野時,將勻速滾動變換為減速滾動旨怠,然后一直滾動目的坐標位置渠驼,使?jié)L動效果更真實,這是由
onTargetFound()
方法決定鉴腻。
剛剛不是留了一個疑問么迷扇?就是正常模式下SmoothScroller通過setTargetPosition()
方法設置的ItemView只能滾動到與RecyclerView邊緣對齊百揭,而解決這個局限的處理方式就是在SmoothScroller的onTargetFound()
方法中了。onTargetFound()
方法會在SmoothScroller滾動過程中蜓席,targetSnapView被layout出來時調用器一。而這個時候利用calculateDistanceToFinalSnap()
方法得到targetSnapView當前坐標與目的坐標之間的距離,然后通過Action.update()
方法改變當前SmoothScroller的狀態(tài)厨内,讓SmoothScroller根據(jù)新的滾動距離祈秕、新的滾動時間、新的滾動差值器來滾動雏胃,這樣既能將targetSnapView滾動到目的坐標位置踢步,又能實現(xiàn)減速滾動,使得滾動效果更真實丑掺。
從圖中可以看到获印,很多時候targetSnapView被layout的時候(onTargetFound()
方法被調用)并不是緊挨著界面上的Item,而是會有一定的提前街州,這是由于RecyclerView為了優(yōu)化性能兼丰,提高流暢度,在滑動滾動的時候會有一個預加載的過程唆缴,提前將Item給layout出來了鳍征,這個知識點涉及到的內容很多,這里做個理解就可以了面徽,不詳細細展開了艳丛,以后有時間會專門講下RecyclerView的相關原理機制。
到了這里趟紊,整理一下前面的思路:SnapHelper實現(xiàn)了OnFlingListener這個接口氮双,該接口中的onFling()
方法會在RecyclerView觸發(fā)Fling操作時調用。在onFling()
方法中判斷當前方向上的速率是否足夠做滾動操作霎匈,如果速率足夠大就調用snapFromFling()
方法實現(xiàn)滾動相關的邏輯戴差。在snapFromFling()
方法中會創(chuàng)建一個SmoothScroller,并且根據(jù)速率計算出滾動停止時的位置铛嘱,將該位置設置給SmoothScroller并啟動滾動暖释。而滾動的操作都是由SmoothScroller全權負責,它可以控制Item的滾動速度(剛開始是勻速)墨吓,并且在滾動到targetSnapView被layout時變換滾動速度(轉換成減速)球匕,以讓滾動效果更加真實。
所以帖烘,SnapHelper輔助RecyclerView實現(xiàn)滾動對齊就是通過給RecyclerView設置OnScrollerListenerh和OnFlingListener這兩個監(jiān)聽器實現(xiàn)的亮曹。
LinearSnapHelper
SnapHelper輔助RecyclerView滾動對齊的框架已經(jīng)搭好了,子類只要根據(jù)對齊方式實現(xiàn)那三個抽象方法就可以了。以LinearSnapHelper為例乾忱,看它到底怎么實現(xiàn)SnapHelper的三個抽象方法讥珍,從而讓ItemView滾動居中對齊:
calculateDistanceToFinalSnap()
@Override
public int[] calculateDistanceToFinalSnap(
@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
int[] out = new int[2];
//水平方向滾動,則計算水平方向需要滾動的距離,否則水平方向的滾動距離為0
if (layoutManager.canScrollHorizontally()) {
out[0] = distanceToCenter(layoutManager, targetView,
getHorizontalHelper(layoutManager));
} else {
out[0] = 0;
}
//豎直方向滾動,則計算豎直方向需要滾動的距離,否則水平方向的滾動距離為0
if (layoutManager.canScrollVertically()) {
out[1] = distanceToCenter(layoutManager, targetView,
getVerticalHelper(layoutManager));
} else {
out[1] = 0;
}
return out;
}
該方法是返回第二個傳參對應的view到RecyclerView中間位置的距離,可以支持水平方向滾動和豎直方向滾動兩個方向的計算窄瘟。最主要的計算距離的這個方法distanceToCenter()
:
private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,
@NonNull View targetView, OrientationHelper helper) {
//找到targetView的中心坐標
final int childCenter = helper.getDecoratedStart(targetView) +
(helper.getDecoratedMeasurement(targetView) / 2);
final int containerCenter;
//找到容器(RecyclerView)的中心坐標
if (layoutManager.getClipToPadding()) {
containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
containerCenter = helper.getEnd() / 2;
}
//兩個中心坐標的差值就是targetView需要滾動的距離
return childCenter - containerCenter;
}
可以看到衷佃,就是計算對應的view的中心坐標到RecyclerView中心坐標之間的距離,該距離就是此view需要滾動的距離蹄葱。
findSnapView()
@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return findCenterView(layoutManager, getVerticalHelper(layoutManager));
} else if (layoutManager.canScrollHorizontally()) {
return findCenterView(layoutManager, getHorizontalHelper(layoutManager));
}
return null;
}
尋找SnapView氏义,這里的目的坐標就是RecyclerView中間位置坐標,可以看到會根據(jù)layoutManager的布局方式(水平布局方式或者豎向布局方式)區(qū)分計算图云,但最終都是通過findCenterView()
方法來找snapView的惯悠。
private View findCenterView(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
View closestChild = null;
//找到RecyclerView的中心坐標
final int center;
if (layoutManager.getClipToPadding()) {
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
center = helper.getEnd() / 2;
}
int absClosest = Integer.MAX_VALUE;
//遍歷當前l(fā)ayoutManager中所有的ItemView
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
//ItemView的中心坐標
int childCenter = helper.getDecoratedStart(child) +
(helper.getDecoratedMeasurement(child) / 2);
//計算此ItemView與RecyclerView中心坐標的距離
int absDistance = Math.abs(childCenter - center);
//對比每個ItemView距離到RecyclerView中心點的距離,找到那個最靠近中心的ItemView然后返回
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
注釋解釋得很清楚竣况,就不重復了克婶。
findTargetSnapPosition()
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
int velocityY) {
//判斷l(xiāng)ayoutManager是否實現(xiàn)了RecyclerView.SmoothScroller.ScrollVectorProvider這個接口
if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
return RecyclerView.NO_POSITION;
}
final int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return RecyclerView.NO_POSITION;
}
//找到snapView
final View currentView = findSnapView(layoutManager);
if (currentView == null) {
return RecyclerView.NO_POSITION;
}
final int currentPosition = layoutManager.getPosition(currentView);
if (currentPosition == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION;
}
RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider =
(RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager;
// 通過ScrollVectorProvider接口中的computeScrollVectorForPosition()方法
// 來確定layoutManager的布局方向
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
if (vectorForEnd == null) {
return RecyclerView.NO_POSITION;
}
int vDeltaJump, hDeltaJump;
if (layoutManager.canScrollHorizontally()) {
//layoutManager是橫向布局,并且內容超出一屏丹泉,canScrollHorizontally()才返回true
//估算fling結束時相對于當前snapView位置的橫向位置偏移量
hDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getHorizontalHelper(layoutManager), velocityX, 0);
//vectorForEnd.x < 0代表layoutManager是反向布局的情萤,就把偏移量取反
if (vectorForEnd.x < 0) {
hDeltaJump = -hDeltaJump;
}
} else {
//不能橫向滾動,橫向位置偏移量當然就為0
hDeltaJump = 0;
}
//豎向的原理同上
if (layoutManager.canScrollVertically()) {
vDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getVerticalHelper(layoutManager), 0, velocityY);
if (vectorForEnd.y < 0) {
vDeltaJump = -vDeltaJump;
}
} else {
vDeltaJump = 0;
}
//根據(jù)layoutManager的橫豎向布局方式摹恨,最終橫向位置偏移量和豎向位置偏移量二選一筋岛,作為fling的位置偏移量
int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;
if (deltaJump == 0) {
return RecyclerView.NO_POSITION;
}
//當前位置加上偏移位置,就得到fling結束時的位置晒哄,這個位置就是targetPosition
int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
targetPos = 0;
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1;
}
return targetPos;
}
RecyclerView的layoutManager很靈活睁宰,有兩種布局方式(橫向布局和縱向布局),每種布局方式有兩種布局方向(正向布局和反向布局)寝凌。這個方法在計算targetPosition的時候把布局方式和布局方向都考慮進去了柒傻。布局方式可以通過layoutManager.canScrollHorizontally()
/layoutManager.canScrollVertically()
來判斷,布局方向就通過RecyclerView.SmoothScroller.ScrollVectorProvider
這個接口中的computeScrollVectorForPosition()
方法來判斷硫兰。
所以SnapHelper為了適配layoutManager的各種情況诅愚,特意要求只有實現(xiàn)了RecyclerView.SmoothScroller.ScrollVectorProvider
接口的layoutManager才能使用SnapHelper進行輔助滾動對齊。官方提供的LinearLayoutManager劫映、GridLayoutManager和StaggeredGridLayoutManager都實現(xiàn)了這個接口,所以都支持SnapHelper刹前。
這幾個方法在計算位置的時候用的是OrientationHelper這個工具類泳赋,它是LayoutManager用于測量child的一個輔助類,可以根據(jù)Layoutmanager的布局方式和布局方向來計算得到ItemView的大小位置等信息喇喉。
從源碼中可以看到findTargetSnapPosition()
會先找到fling操作被觸發(fā)時界面上的snapView(因為findTargetSnapPosition()
方法是在onFling()
方法中被調用的)祖今,得到對應的snapPosition,然后通過estimateNextPositionDiffForFling()
方法估算位置偏移量,snapPosition加上位置偏移量就得到最終滾動結束時的位置千诬,也就是targetSnapPosition耍目。
這里有一個點需要注意一下,就是在找targetSnapPosition之前是需要先找一個參考位置的徐绑,該參考位置就是snapPosition了邪驮。這是因為當前界面上不同的ItemView位置相差比較大,用snapPosition作參考位置傲茄,會使得參考位置加上位置偏移量得到的targetSnapPosition最接近目的坐標位置毅访,從而讓后續(xù)的坐標對齊調整更加自然。
看下estimateNextPositionDiffForFling()
方法怎么估算位置偏移量的:
private int estimateNextPositionDiffForFling(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper, int velocityX, int velocityY) {
//計算滾動的總距離盘榨,這個距離受到觸發(fā)fling時的速度的影響
int[] distances = calculateScrollDistance(velocityX, velocityY);
//計算每個ItemView的長度
float distancePerChild = computeDistancePerChild(layoutManager, helper);
if (distancePerChild <= 0) {
return 0;
}
//這里其實就是根據(jù)是橫向布局還是縱向布局喻粹,來取對應布局方向上的滾動距離
int distance =
Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
//distance的正負值符號表示滾動方向,數(shù)值表示滾動距離草巡。橫向布局方式守呜,內容從右往左滾動為正;豎向布局方式山憨,內容從下往上滾動為正
// 滾動距離/item的長度=滾動item的個數(shù)弛饭,這里取計算結果的整數(shù)部分
if (distance > 0) {
return (int) Math.floor(distance / distancePerChild);
} else {
return (int) Math.ceil(distance / distancePerChild);
}
}
可以看到就是用滾動總距離除以itemview的長度,從而估算得到需要滾動的item數(shù)量萍歉,此數(shù)值就是位置偏移量侣颂。而滾動距離是通過SnapHelper的calculateScrollDistance()
方法得到的,ItemView的長度是通過computeDistancePerChild()
方法計算出來枪孩。
看下這兩個方法:
private float computeDistancePerChild(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
View minPosView = null;
View maxPosView = null;
int minPos = Integer.MAX_VALUE;
int maxPos = Integer.MIN_VALUE;
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return INVALID_DISTANCE;
}
//循環(huán)遍歷layoutManager的itemView憔晒,得到最小position和最大position,以及對應的view
for (int i = 0; i < childCount; i++) {
View child = layoutManager.getChildAt(i);
final int pos = layoutManager.getPosition(child);
if (pos == RecyclerView.NO_POSITION) {
continue;
}
if (pos < minPos) {
minPos = pos;
minPosView = child;
}
if (pos > maxPos) {
maxPos = pos;
maxPosView = child;
}
}
if (minPosView == null || maxPosView == null) {
return INVALID_DISTANCE;
}
//最小位置和最大位置肯定就是分布在layoutManager的兩端蔑舞,但是無法直接確定哪個在起點哪個在終點(因為有正反向布局)
//所以取兩者中起點坐標小的那個作為起點坐標
//終點坐標的取值一樣的道理
int start = Math.min(helper.getDecoratedStart(minPosView),
helper.getDecoratedStart(maxPosView))拒担;
int end = Math.max(helper.getDecoratedEnd(minPosView),
helper.getDecoratedEnd(maxPosView));
//終點坐標減去起點坐標得到這些itemview的總長度
int distance = end - start;
if (distance == 0) {
return INVALID_DISTANCE;
}
// 總長度 / itemview個數(shù) = itemview平均長度
return 1f * distance / ((maxPos - minPos) + 1);
}
可以發(fā)現(xiàn)computeDistancePerChild()
方法也用總長度除以ItemView個數(shù)的方式來得到ItemView平均長度,并且也支持了layoutManager不同的布局方式和布局方向攻询。
public int[] calculateScrollDistance(int velocityX, int velocityY) {
int[] outDist = new int[2];
//mGravityScroller是一個Scroller从撼,通過fling()方法模擬fling操作,通過將起點位置都置為0钧栖,此時得到的終點位置就是滾動的距離
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;
}
calculateScrollDistance()
是SnapHelper中的方法低零,它使用到的mGravityScroller是一個在attachToRecyclerView()
中初始化的Scroller對象,通過Scroller.fling()
方法模擬fling操作拯杠,將fling的起點位置為設置為0掏婶,此時得到的終點位置就是fling的距離。這個距離會有正負符號之分潭陪,表示滾動的方向雄妥。
現(xiàn)在明白了吧最蕾,LinearSnapHelper的主要功能就是通過實現(xiàn)SnapHelper的三個抽象方法,從而實現(xiàn)輔助RecyclerView滾動Item對齊中心位置老厌。
自定義SnapHelper
經(jīng)過了以上分析瘟则,了解了SnapHelper的工作原理之后,自定義SnapHelper也就更加自如了≈Τ樱現(xiàn)在來看下Google Play主界面的效果醋拧。
可以看到該效果是一個類似Gallery的橫向列表滑動控件,很明顯可以用RecyclerView來實現(xiàn)宿百,而滾動后的ItemView是對齊RecyclerView的左邊緣位置趁仙,這種對齊效果當仍不讓就使用了SnapHelper來實現(xiàn)了。這里就主要講下這個SnapHelper怎么實現(xiàn)的垦页。
創(chuàng)建一個GallerySnapHelper繼承SnapHelper實現(xiàn)它的三個抽象方法:
-
calculateDistanceToFinalSnap()
:計算SnapView當前位置與目標位置的距離@Override public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) { int[] out = new int[2]; if (layoutManager.canScrollHorizontally()) { out[0] = distanceToStart(targetView, getHorizontalHelper(layoutManager)); } else { out[0] = 0; } return out; } //targetView的start坐標與RecyclerView的paddingStart之間的差值 //就是需要滾動調整的距離 private int distanceToStart(View targetView, OrientationHelper helper) { return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding(); }
?
-
findSnapView()
:找到當前時刻的SnapView雀费。@Override public View findSnapView(RecyclerView.LayoutManager layoutManager) { return findStartView(layoutManager, getHorizontalHelper(layoutManager)); } private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) { if (layoutManager instanceof LinearLayoutManager) { //找出第一個可見的ItemView的位置 int firstChildPosition = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition(); if (firstChildPosition == RecyclerView.NO_POSITION) { return null; } //找到最后一個完全顯示的ItemView,如果該ItemView是列表中的最后一個 //就說明列表已經(jīng)滑動最后了痊焊,這時候就不應該根據(jù)第一個ItemView來對齊了 //要不然由于需要跟第一個ItemView對齊最后一個ItemView可能就一直無法完全顯示盏袄, //所以這時候直接返回null表示不需要對齊 if (((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition() == layoutManager.getItemCount() - 1) { return null; } View firstChildView = layoutManager.findViewByPosition(firstChildPosition); //如果第一個ItemView被遮住的長度沒有超過一半,就取該ItemView作為snapView //超過一半薄啥,就把下一個ItemView作為snapView if (helper.getDecoratedEnd(firstChildView) >= helper.getDecoratedMeasurement(firstChildView) / 2 && helper.getDecoratedEnd(firstChildView) > 0) { return firstChildView; } else { return layoutManager.findViewByPosition(firstChildPosition + 1); } } else { return null; } }
-
findTargetSnapPosition()
: 在觸發(fā)fling時找到targetSnapPosition辕羽。@Override public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) { if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) { return RecyclerView.NO_POSITION; } final int itemCount = layoutManager.getItemCount(); if (itemCount == 0) { return RecyclerView.NO_POSITION; } final View currentView = findSnapView(layoutManager); if (currentView == null) { return RecyclerView.NO_POSITION; } final int currentPosition = layoutManager.getPosition(currentView); if (currentPosition == RecyclerView.NO_POSITION) { return RecyclerView.NO_POSITION; } RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider = (RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager; PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1); if (vectorForEnd == null) { return RecyclerView.NO_POSITION; } int deltaJump; if (layoutManager.canScrollHorizontally()) { deltaJump = estimateNextPositionDiffForFling(layoutManager, getHorizontalHelper(layoutManager), velocityX, 0); if (vectorForEnd.x < 0) { deltaJump = -deltaJump; } } else { deltaJump = 0; } if (deltaJump == 0) { return RecyclerView.NO_POSITION; } int targetPos = currentPosition + deltaJump; if (targetPos < 0) { targetPos = 0; } if (targetPos >= itemCount) { targetPos = itemCount - 1; } return targetPos; }
? 這個方法跟LinearSnapHelper的實現(xiàn)基本是一樣的。
就這樣實現(xiàn)三個抽象方法之后看下效果:
發(fā)現(xiàn)基本能像Google Play那樣進行對齊左側邊緣垄惧。但作為一個有理想有文化有追求的程序員刁愿,怎么可以那么容易滿足呢?到逊!極致才是最終的目標铣口!沒時間解釋了,快上車觉壶!
目前的效果跟Google Play中的效果主要還有兩個差異:
- 滾動速度明顯慢于Google Play的橫向列表滾動速度脑题,導致滾動起來感覺比較拖沓,看起來不是很干脆的樣子铜靶。
- Google Play那個橫向列表一次滾動的個數(shù)最多就是一頁的Item個數(shù)叔遂,而目前的效果滑得比較快時會滾得很遠。
其實這兩個問題如果你理解了我上面所講的SnapHelper的原理争剿,解決起來就很容易了已艰。
對于滾動速度偏慢的問題,由于這個fling過程是通過SnapHelper的SmoothScroller控制的秒梅,我們在分析創(chuàng)建SmoothScroller對象的時候就提到SmoothScroller的calculateSpeedPerPixel()
方法是在定義滾動速度的旗芬,那復寫SnapHelper的createSnapScroller()
方法重新定義一個SmoothScroller不就可以了么?!
//SnapHelper中該值為100捆蜀,這里改為40
private static final float MILLISECONDS_PER_INCH = 40f;
@Nullable
protected LinearSmoothScroller createSnapScroller(final 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) {
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;
}
};
}
可以看到,代碼跟SnapHelper里是一模一樣的,就只是改了MILLISECONDS_PER_INCH這個數(shù)值而已辆它,使得calculateSpeedPerPixel()
返回值變小誊薄,從而讓SmoothScroller的滾動速度更快。
對于一次滾動太多個Item的問題锰茉,就需要對他滾動的個數(shù)做下限制了呢蔫。那在哪里對滾動的數(shù)量做限制呢?findTargetSnapPosition()
方法里飒筑! 該方法的作用就是在尋找需要滾動到哪個位置的片吊,不在這里還能在哪里?协屡!直接看代碼:
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
...
//計算一屏的item數(shù)
int deltaThreshold = layoutManager.getWidth() / getHorizontalHelper(layoutManager).getDecoratedMeasurement(currentView);
int deltaJump;
if (layoutManager.canScrollHorizontally()) {
deltaJump = estimateNextPositionDiffForFling(layoutManager,
getHorizontalHelper(layoutManager), velocityX, 0);
//對估算出來的位置偏移量進行閾值判斷俏脊,最多只能滾動一屏的Item個數(shù)
if (deltaJump > deltaThreshold) {
deltaJump = deltaThreshold;
}
if (deltaJump < -deltaThreshold) {
deltaJump = -deltaThreshold;
}
if (vectorForEnd.x < 0) {
deltaJump = -hDeltaJump;
}
} else {
deltaJump = 0;
}
...
}
可以看到就是對估算出來的位置偏移量做下大小限制而已,就這么簡單肤晓!
通過這樣調整爷贫,效果已經(jīng)跟Google Play基本一樣了,我猜Google Play也是這樣做的补憾!看效果:
附錄
?
?