深度分析:Google Play列表滑動效果

googleplay.gif

作者博客

http://www.jianshu.com/u/6b147fcd94e6

文章目錄

  1. 簡介

  2. 原理剖析

    1. Fling操作

    2. 三個抽象方法

    3. attachToRecyclerView()

    4. snapToTargetExistingView()

    5. setupCallbacks()和destroyCallbacks()

    6. LinearSnapHelper

1

簡介

RecyclerView在24.2.0版本中新增了SnapHelper這個輔助類,用於輔助RecyclerView在滾動結束時將Item對齊到某個位置。特別是列表橫向滑動時,很多時候不會讓列表滑到任意位置,而是會有一定的規則限制,這時候就可以通過SnapHelper來定義對齊規則了。

SnapHelper是一個抽象類,官方提供了一個LinearSnapHelper的子類,可以讓RecyclerView滾動停止時相應的Item停留中間位置。25.1.0版本中官方又提供了一個PagerSnapHelper的子類,可以使RecyclerView像ViewPager一樣的效果,一次只能滑一頁,而且居中顯示。

這兩個子類使用方式也很簡單,只需要創建對象之後調用attachToRecyclerView()附着到對應的RecyclerView對象上就可以了。

2

原理剖析

Fling操作

首先來了解一個概念,手指在屏幕上滑動RecyclerView然後鬆手,RecyclerView中的內容會順着慣性繼續往手指滑動的方向繼續滾動直到停止,這個過程叫做Fling。Fling操作從手指離開屏幕瞬間被觸發,在滾動停止時結束。

三個抽象方法

SnapHelper是一個抽象類,它有三個抽象方法:

該方法會根據觸發Fling操作的速率(參數velocityX和參數velocityY)來找到RecyclerView需要滾動到哪個位置,該位置對應的ItemView就是那個需要進行對齊的列表項。我們把這個位置稱爲targetSnapPosition,對應的View稱爲targetSnapView。如果找不到targetSnapPosition,就返回RecyclerView.NO_POSITION。

該方法會找到當前layoutManager上最接近對齊位置的那個view,該view稱爲SanpView,對應的position稱爲SnapPosition。如果返回null,就表示沒有需要對齊的View,也就不會做滾動對齊調整。

這個方法會計算第二個參數對應的ItemView當前的座標與需要對齊的座標之間的距離。該方法返回一個大小爲2的int數組,分別對應x軸和y軸方向上的距離。

SnapView.png

attachToRecyclerView()

現在來看attachToRecyclerView()這個方法,SnapHelper正是通過該方法附着到RecyclerView上,從而實現輔助RecyclerView滾動對齊操作。源碼如下:

可以看到,在attachToRecyclerView()方法中會清掉SnapHelper之前保存的RecyclerView對象的回調(如果有的話),對新設置進來的RecyclerView對象設置回調,然後初始化一個Scroller對象,最後調用snapToTargetExistingView()方法對SnapView進行對齊調整。

snapToTargetExistingView()

該方法的作用是對SnapView進行滾動調整,以使得SnapView達到對齊效果。源碼如下:

可以看到,snapToTargetExistingView()方法就是先找到SnapView,然後計算SnapView當前座標到目的座標之間的距離,然後調用RecyclerView.smoothScrollBy()方法實現對RecyclerView內容的平滑滾動,從而將SnapView移到目標位置,達到對齊效果。RecyclerView.smoothScrollBy()這個方法的實現原理這裏就不展開了 ,它的作用就是根據參數平滑滾動RecyclerView的中的ItemView相應的距離。

setupCallbacks()和destroyCallbacks()

再看下SnapHelper對RecyclerView設置了哪些回調:

可以看出RecyclerView設置的回調有兩個:一個是OnScrollListener對象mScrollListener.還有一個是OnFlingListener對象。由於SnapHelper實現了OnFlingListener接口,所以這個對象就是SnapHelper自身了.

先看下mScrollListener這個變量在怎樣實現的.

該滾動監聽器的實現很簡單,只是在正常滾動停止的時候調用了snapToTargetExistingView()方法對targetView進行滾動調整,以確保停止的位置是在對應的座標上,這就是RecyclerView添加該OnScrollListener的目的。

除了OnScrollListener這個監聽器,還對RecyclerView還設置了OnFlingListener這個監聽器,而這個監聽器就是SnapHelper自身。因爲SnapHelper實現了RecyclerView.OnFlingListener接口。我們先來看看RecyclerView.OnFlingListener這個接口。

這個接口中就只有一個onFling()方法,該方法會在RecyclerView開始做fling操作時被調用。我們來看看SnapHelper怎麼實現onFling()方法:

註釋解釋得很清楚。看下snapFromFling()怎麼操作的:

可以看到,snapFromFling()方法會先判斷layoutManager是否實現了ScrollVectorProvider接口,如果沒有實現該接口就不允許通過該方法做滾動操作。那爲啥一定要實現該接口呢?待會再來解釋。接下來就去創建平滑滾動器SmoothScroller的一個實例,layoutManager可以通過該平滑滾動器來進行滾動操作。SmoothScroller需要設置一個滾動的目標位置,我們將通過findTargetSnapPosition()方法來計算得到的targetSnapPosition給它,告訴滾動器要滾到這個位置,然後就啓動SmoothScroller進行滾動操作。

但是這裏有一點需要注意一下,默認情況下通過setTargetPosition()方法設置的SmoothScroller只能將對應位置的ItemView滾動到與RecyclerView的邊界對齊,那怎麼實現將該ItemView滾動到我們需要對齊的目標位置呢?就得對SmoothScroller進行一下處理了。

看下平滑滾動器RecyclerView.SmoothScroller,這個東西是通過createSnapScroller()方法創建得到的:

通過以上的分析可以看到,createSnapScroller()創建的是一個LinearSmoothScroller,並且在創建該LinearSmoothScroller的時候主要考慮兩個方面:

  • 第一個是滾動速率,由calculateSpeedPerPixel()方法決定;

  • 第二個是在滾動過程中,targetView即將要進入到視野時,將勻速滾動變換爲減速滾動,然後一直滾動目的座標位置,使滾動效果更真實,這是由onTargetFound()方法決定。

剛剛不是留了一個疑問麼?就是正常模式下SmoothScroller通過setTargetPosition()方法設置的ItemView只能滾動到與RecyclerView邊緣對齊,而解決這個侷限的處理方式就是在SmoothScroller的onTargetFound()方法中了。onTargetFound()方法會在SmoothScroller滾動過程中,targetSnapView被layout出來時調用。而這個時候利用calculateDistanceToFinalSnap()方法得到targetSnapView當前座標與目的座標之間的距離,然後通過Action.update()方法改變當前SmoothScroller的狀態,讓SmoothScroller根據新的滾動距離、新的滾動時間、新的滾動差值器來滾動,這樣既能將targetSnapView滾動到目的座標位置,又能實現減速滾動,使得滾動效果更真實。

onTargetFound

從圖中可以看到,很多時候targetSnapView被layout的時候(onTargetFound()方法被調用)並不是緊挨着界面上的Item,而是會有一定的提前,這是由於RecyclerView爲了優化性能,提高流暢度,在滑動滾動的時候會有一個預加載的過程,提前將Item給layout出來了,這個知識點涉及到的內容很多,這裏做個理解就可以了,不詳細細展開了,以後有時間會專門講下RecyclerView的相關原理機制。

到了這裏,整理一下前面的思路:SnapHelper實現了OnFlingListener這個接口,該接口中的onFling()方法會在RecyclerView觸發Fling操作時調用。在onFling()方法中判斷當前方向上的速率是否足夠做滾動操作,如果速率足夠大就調用snapFromFling()方法實現滾動相關的邏輯。在snapFromFling()方法中會創建一個SmoothScroller,並且根據速率計算出滾動停止時的位置,將該位置設置給SmoothScroller並啓動滾動。而滾動的操作都是由SmoothScroller全權負責,它可以控制Item的滾動速度(剛開始是勻速),並且在滾動到targetSnapView被layout時變換滾動速度(轉換成減速),以讓滾動效果更加真實。

所以,SnapHelper輔助RecyclerView實現滾動對齊就是通過給RecyclerView設置OnScrollerListenerh和OnFlingListener這兩個監聽器實現的。

LinearSnapHelper

SnapHelper輔助RecyclerView滾動對齊的框架已經搭好了,子類只要根據對齊方式實現那三個抽象方法就可以了。以LinearSnapHelper爲例,看它到底怎麼實現SnapHelper的三個抽象方法,從而讓ItemView滾動居中對齊:

calculateDistanceToFinalSnap()

該方法是返回第二個傳參對應的view到RecyclerView中間位置的距離,可以支持水平方向滾動和豎直方向滾動兩個方向的計算。最主要的計算距離的這個方法

distanceToCenter():

可以看到,就是計算對應的view的中心座標到RecyclerView中心座標之間的距離,該距離就是此view需要滾動的距離。

findSnapView()

尋找SnapView,這裏的目的座標就是RecyclerView中間位置座標,可以看到會根據layoutManager的佈局方式(水平佈局方式或者豎向佈局方式)區分計算,但最終都是通過findCenterView()方法來找snapView的。

註釋解釋得很清楚,就不重複了。

findTargetSnapPosition()

RecyclerView的layoutManager很靈活,有兩種佈局方式(橫向佈局和縱向佈局),每種佈局方式有兩種佈局方向(正向佈局和反向佈局)。這個方法在計算targetPosition的時候把佈局方式和佈局方向都考慮進去了。佈局方式可以通過layoutManager.canScrollHorizontally()/layoutManager.canScrollVertically()來判斷,佈局方向就通過RecyclerView.SmoothScroller.ScrollVectorProvider這個接口中的computeScrollVectorForPosition()方法來判斷。

所以SnapHelper爲了適配layoutManager的各種情況,特意要求只有實現了RecyclerView.SmoothScroller.ScrollVectorProvider接口的layoutManager才能使用SnapHelper進行輔助滾動對齊。官方提供的LinearLayoutManager、GridLayoutManager和StaggeredGridLayoutManager都實現了這個接口,所以都支持SnapHelper。

這幾個方法在計算位置的時候用的是OrientationHelper這個工具類,它是LayoutManager用於測量child的一個輔助類,可以根據Layoutmanager的佈局方式和佈局方向來計算得到ItemView的大小位置等信息。

從源碼中可以看到findTargetSnapPosition()會先找到fling操作被觸發時界面上的snapView(因爲findTargetSnapPosition()方法是在onFling()方法中被調用的),得到對應的snapPosition,然後通過estimateNextPositionDiffForFling()方法估算位置偏移量,snapPosition加上位置偏移量就得到最終滾動結束時的位置,也就是targetSnapPosition。

這裏有一個點需要注意一下,就是在找targetSnapPosition之前是需要先找一個參考位置的,該參考位置就是snapPosition了。這是因爲當前界面上不同的ItemView位置相差比較大,用snapPosition作參考位置,會使得參考位置加上位置偏移量得到的targetSnapPosition最接近目的座標位置,從而讓後續的座標對齊調整更加自然。

看下estimateNextPositionDiffForFling()方法怎麼估算位置偏移量的:

可以看到就是用滾動總距離除以itemview的長度,從而估算得到需要滾動的item數量,此數值就是位置偏移量。而滾動距離是通過SnapHelper的calculateScrollDistance()方法得到的,ItemView的長度是通過computeDistancePerChild()方法計算出來。

看下這兩個方法:

可以發現computeDistancePerChild()方法也用總長度除以ItemView個數的方式來得到ItemView平均長度,並且也支持了layoutManager不同的佈局方式和佈局方向。

calculateScrollDistance()是SnapHelper中的方法,它使用到的mGravityScroller是一個在attachToRecyclerView()中初始化的Scroller對象,通過Scroller.fling()方法模擬fling操作,將fling的起點位置爲設置爲0,此時得到的終點位置就是fling的距離。這個距離會有正負符號之分,表示滾動的方向。

現在明白了吧,LinearSnapHelper的主要功能就是通過實現SnapHelper的三個抽象方法,從而實現輔助RecyclerView滾動Item對齊中心位置。

自定義SnapHelper

經過了以上分析,瞭解了SnapHelper的工作原理之後,自定義SnapHelper也就更加自如了。現在來看下Google Play主界面的效果。

可以看到該效果是一個類似Gallery的橫向列表滑動控件,很明顯可以用RecyclerView來實現,而滾動後的ItemView是對齊RecyclerView的左邊緣位置,這種對齊效果當仍不讓就使用了SnapHelper來實現了。這裏就主要講下這個SnapHelper怎麼實現的。

創建一個GallerySnapHelper繼承SnapHelper實現它的三個抽象方法:

calculateDistanceToFinalSnap():計算SnapView當前位置與目標位置的距離

findSnapView():找到當前時刻的SnapView。

findTargetSnapPosition(): 在觸發fling時找到targetSnapPosition。

這個方法跟LinearSnapHelper的實現基本是一樣的。

就這樣實現三個抽象方法之後看下效果:

發現基本能像Google Play那樣進行對齊左側邊緣。但作爲一個有理想有文化有追求的程序員,怎麼可以那麼容易滿足呢?!極致纔是最終的目標!沒時間解釋了,快上車!

目前的效果跟Google Play中的效果主要還有兩個差異:

  • 滾動速度明顯慢於Google Play的橫向列表滾動速度,導致滾動起來感覺比較拖沓,看起來不是很乾脆的樣子。

  • Google Play那個橫向列表一次滾動的個數最多就是一頁的Item個數,而目前的效果滑得比較快時會滾得很遠。

其實這兩個問題如果你理解了我上面所講的SnapHelper的原理,解決起來就很容易了。

對於滾動速度偏慢的問題,由於這個fling過程是通過SnapHelper的SmoothScroller控制的,我們在分析創建SmoothScroller對象的時候就提到SmoothScroller的calculateSpeedPerPixel()方法是在定義滾動速度的,那複寫SnapHelper的createSnapScroller()方法重新定義一個SmoothScroller不就可以了麼?!

可以看到,代碼跟SnapHelper裏是一模一樣的,就只是改了MILLISECONDS_PER_INCH這個數值而已,使得calculateSpeedPerPixel()返回值變小,從而讓SmoothScroller的滾動速度更快。

對於一次滾動太多個Item的問題,就需要對他滾動的個數做下限制了。那在哪裏對滾動的數量做限制呢?findTargetSnapPosition()方法裏! 該方法的作用就是在尋找需要滾動到哪個位置的,不在這裏還能在哪裏?!直接看代碼:

可以看到就是對估算出來的位置偏移量做下大小限制而已,就這麼簡單!

通過這樣調整,效果已經跟Google Play基本一樣了,我猜Google Play也是這樣做的!看效果:

源碼地址:

https://github.com/zhimaochen/SnapHelperDemo

100篇精選Android乾貨