寫在前面
博客中的demo上傳到了github NestedScrollingProject茂装,歡迎各位同學(xué)下載&star。
一善延、吸頂效果&RecyclerView源碼簡析
吸頂效果是CoordinatorLayout中的一個(gè)基礎(chǔ)功能少态,它的本質(zhì)就是嵌套滑動(dòng),因此我們可以自己嘗試去實(shí)現(xiàn)它易遣。同時(shí)本章將會(huì)對(duì)RecyclerView源碼中的嵌套滑動(dòng)部分進(jìn)行分析彼妻,深入理解嵌套滑動(dòng)事件的分發(fā)與回調(diào)。
1.1 吸頂效果展示
1.2 嵌套滑動(dòng)API介紹
上面所展示的界面是一個(gè)線性布局豆茫,如圖所示:
外部父Layout包裹ImageView侨歉、TextView和RecyclerView,如果我們希望滑動(dòng)RecyclerView的時(shí)候能先將ImageView滑動(dòng)上去揩魂,隨后使TextView吸頂思恐,我們?cè)撛趺醋瞿兀?/p>
這里就用到嵌套滑動(dòng)颅痊,假設(shè)當(dāng)前用戶手指在RecyclerView向上劃動(dòng)垒迂,我們需要將RecyclerView的滑動(dòng)事件先傳遞給父布局堂污,如果父布局發(fā)現(xiàn)頭部的ImageView還在顯示茅特,那么先消耗該事件并將整個(gè)父布局中的所有內(nèi)容向上移動(dòng);如果圖片已經(jīng)上滑至不顯示棋枕,那么將滑動(dòng)事件交給RecyclerView處理。
手指在RecyclerView上劃時(shí)如圖所示妒峦,此時(shí)LinearLayout中的所有內(nèi)容都會(huì)向上滾動(dòng)重斑,直到TextView吸頂,再開始滑動(dòng)RecyclerView肯骇。注意:RecyclerView的高度其實(shí)是界面的高度減去TexView的高度窥浪,比布局文件圖中畫的高度要高。
根據(jù)上面的流程不難發(fā)現(xiàn)笛丙,嵌套滑動(dòng)由RecyclerView主動(dòng)發(fā)起漾脂,父View被動(dòng)接受,并且父View可以先于子View處理滑動(dòng)事件胚鸯。舉個(gè)栗子骨稿,假設(shè)在一次事件中手指在RecyclerView向上滑動(dòng)dy
,那么大體的流程如下:
① RecyclerView判斷是否有父View能接受嵌套滑動(dòng)姜钳,如果有坦冠,則將事件傳遞給父View。
② 父View收到該滑動(dòng)事件哥桥,此時(shí)父View判斷當(dāng)前圖片是否還在展示辙浑,如果還在展示,則父View向上滑動(dòng)拟糕。但是父View不一定會(huì)在每次事件中都將dy
全部消耗掉(例如滑動(dòng)到邊緣的時(shí)候)判呕,這里通過一個(gè)值consumed
來保存父Layout消耗的值。
③ 子View根據(jù)父View消耗的距離送滞,計(jì)算出剩余的值dy-consumed
侠草,如果dy-consumed
不為0,則由RecyclerView自己處理犁嗅。
④ 如果RecyclerView消耗完之后剩余的距離還不為0梦抢,則再交由父Layout處理。
想要實(shí)現(xiàn)嵌套滑動(dòng)的子View需要實(shí)現(xiàn)NestedScrollingChild
接口愧哟,里面包含的方法如下奥吩。
public interface NestedScrollingChild {
// 設(shè)置當(dāng)前子View是否支持嵌套滑動(dòng)
void setNestedScrollingEnabled(boolean enabled);
// 當(dāng)前子View是否支持嵌套滑動(dòng)
boolean isNestedScrollingEnabled();
// 開始嵌套滑動(dòng),對(duì)應(yīng)Parent的onStartNestedScroll
boolean startNestedScroll(@ScrollAxis int axes);
// 停止本次嵌套滑動(dòng)蕊梧,對(duì)應(yīng)Parent的onStopNestedScroll
void stopNestedScroll();
// true表示這個(gè)子View有一個(gè)支持嵌套滑動(dòng)的父View
boolean hasNestedScrollingParent();
// 通知父Layout即將開始滑動(dòng)了霞赫,由父View先處理,對(duì)應(yīng)父View的onNestedPreScroll方法
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
// 子View處理完事件再交給父View肥矢,對(duì)應(yīng)父View的onNestedScroll方法
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
// 通知父View開始Fling了端衰,對(duì)應(yīng)Parent的onNestedFling方法
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
// 通知父View要開始fling了叠洗,對(duì)應(yīng)Parent的onNestedPreFling方法
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
想要實(shí)現(xiàn)嵌套滑動(dòng)的父View需要實(shí)現(xiàn)NestedScrollingParent
接口,里面包含的方法如下旅东。
public interface NestedScrollingParent {
// 當(dāng)子View開始滑動(dòng)時(shí)調(diào)用灭抑,返回true表示接受嵌套滑動(dòng)
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
// 接受嵌套滑動(dòng)后進(jìn)行準(zhǔn)備工作
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
// 嵌套滑動(dòng)結(jié)束時(shí)回調(diào)
void onStopNestedScroll(@NonNull View target);
// 父View先處理滑動(dòng)距離dx或dy,consumed[0]保存父Layout在x軸上消耗的距離抵代,consumed[1]保存父Layout在y軸上消耗的距離
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
// 父View處理子View消耗完后剩余的距離
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
// 當(dāng)子View fling時(shí)腾节,會(huì)觸發(fā)這個(gè)回調(diào),consumed代表速度是否被子View消耗
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
// 當(dāng)子View要開始fling時(shí)荤牍,會(huì)先詢問父View是否要攔截本次fling案腺,返回true表示要攔截,那么子View就不會(huì)慣性滑動(dòng)了
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
// 表示目前正在進(jìn)行的嵌套滑動(dòng)的方向康吵,值有:
// ViewCompat.SCROLL_AXIS_HORIZONTAL
// ViewCompat.SCROLL_AXIS_VERTICAL劈榨、SCROLL_AXIS_NONE
@ScrollAxis
int getNestedScrollAxes();
}
可以看到這兩個(gè)接口的方法名都很通俗易懂,子View主動(dòng)觸發(fā)嵌套滑動(dòng)晦嵌,父View被動(dòng)接受觸發(fā)回調(diào)同辣,每一個(gè)嵌套滑動(dòng)事件都會(huì)經(jīng)歷一個(gè)"父-子-父"的分發(fā)流程。以RecyclerView為例惭载,一次嵌套滑動(dòng)事件的執(zhí)行順序如下所示:
-> child.ACTION_DOWN
-> child.startNestedScroll
-> parent.onStartNestedScroll (如果返回false邑闺,則流程終止)
-> parent.onNestedScrollAccepted
-> child.ACTION_MOVE
-> child.dispatchNestedPreScroll
-> parent.onNestedPreScroll
-> child.ACTION_UP
-> chid.stopNestedScroll
-> parent.onStopNestedScroll
-> child.fling
-> child.dispatchNestedPreFling
-> parent.onNestedPreFling
-> child.dispatchNestedFling
-> parent.onNestedFling
那么問題來了,子View主動(dòng)開啟嵌套滑動(dòng)之后父View是怎么接收到的呢棕兼?
那就不得不提兩個(gè)工具類NestedScrollingChildHelper
和NestedScrollingParentHelper
了,這兩個(gè)工具類的作用就是連接父View和子View并完成一些基礎(chǔ)工作靶衍。當(dāng)子View調(diào)用startNestedScroll()
方法時(shí)颅眶,內(nèi)部究竟做了什么呢涛酗?來看一下RecyclerView里的寫法。
@Override
public boolean startNestedScroll(int axes) {
return getScrollingChildHelper().startNestedScroll(axes);
}
emmmm...直接調(diào)用了NestedScrollingChildHelper
的startNestedScroll(axes)
方法,這里的axes
表示方向过蹂,點(diǎn)進(jìn)去看下酷勺。
public boolean startNestedScroll(@ScrollAxis int axes) {
return startNestedScroll(axes, TYPE_TOUCH);
}
這方法是個(gè)套娃坦报,再點(diǎn)進(jìn)去看下片择。
public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
if (hasNestedScrollingParent(type)) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {
ViewParent p = mView.getParent();
View child = mView;
while (p != null) {
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
setNestedScrollingParentForType(type, p);
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
終于看到方法本體了字管,type
參數(shù)表示什么下面再談硫戈,看一下方法做了什么:
mView
表示當(dāng)前這個(gè)子View梭姓,方法里一層一層向上尋找mView的父View铡恕,直到ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)
返回true,也就是此時(shí)的父View實(shí)現(xiàn)了NestedScrollingParent
系列接口并接受此次嵌套滑動(dòng)墙牌〕贩溃看一下ViewParentCompat的onStartNestedScroll(...)
方法漾肮。
public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
int nestedScrollAxes, int type) {
if (parent instanceof NestedScrollingParent2) {
return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
nestedScrollAxes, type);
} else if (type == ViewCompat.TYPE_TOUCH) {
if (Build.VERSION.SDK_INT >= 21) {
// ......
} else if (parent instanceof NestedScrollingParent) {
return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
nestedScrollAxes);
}
}
return false;
}
從這里可以看出來谭溉,嵌套滑動(dòng)的parent
不一定是child
的直接父View,它們中間可能隔了好幾層旅挤。仔細(xì)看一下上面的方法,你會(huì)發(fā)現(xiàn)除了NestedScrollingParent
接口外還有NestedScrollingParent2
接口,那么相比于第1代,NestedScrollingParent2
升級(jí)了什么呢拐辽?
還記得上面提到的type
參數(shù)嗎睁搭?第2代嵌套滑動(dòng)接口通過該參數(shù)區(qū)分當(dāng)前觸發(fā)嵌套滑動(dòng)的是SCROLL事件還是FLING事件锌唾,父View可以統(tǒng)一在onNestedPreScroll()
或onNestedScroll()
方法中進(jìn)行處理。至于這是怎么做到的,我們接著往下看。
1.3 RecyclerView嵌套滑動(dòng)源碼簡析(版本androidx-1.1.0)
現(xiàn)在先讓我們來探究一下嵌套滑動(dòng)的源頭,上面提到,嵌套滑動(dòng)是由子View發(fā)起,父Layout接收的,那么子View究竟在什么時(shí)候開啟嵌套滑動(dòng)呢澄步?RecyclerView在嵌套滑動(dòng)中經(jīng)常作為子View王凑,這里以RecyclerView為例弱睦,來分析其處理嵌套滑動(dòng)的邏輯火惊,該邏輯主要在onTouchEvent()
方法中惶岭,來看一下精簡后的代碼。
@Override
public boolean onTouchEvent(MotionEvent e) {
// 省略部分代碼......
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 手指按下時(shí)嘗試開啟嵌套滑動(dòng), 尋找可以嵌套滑動(dòng)的父Layout
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
} break;
case MotionEvent.ACTION_MOVE: {
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
if (mScrollState == SCROLL_STATE_DRAGGING) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 根據(jù)當(dāng)前的滑動(dòng)方向開始嵌套滑動(dòng), 由父Layout先scroll
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH)) {
// 減去父Layout消耗掉的距離
dx -= mReusableIntPair[0];
dy -= mReusableIntPair[1];
// 更新offsets, 不常用到
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
// 滑動(dòng)已經(jīng)初始化, 阻止父Layout攔截事件
getParent().requestDisallowInterceptTouchEvent(true);
}
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
// RecyclerView內(nèi)部的scroll
if (scrollByInternal(
canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, e)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
} break;
case MotionEvent.ACTION_UP: {
// 手指抬起時(shí)計(jì)算速度, 開啟fling
mVelocityTracker.addMovement(vtev);
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
final float xvel = canScrollHorizontally
? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
final float yvel = canScrollVertically
? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
// 如果某個(gè)方向上的速度不為0就調(diào)用fling方法, 否則設(shè)置RecyclerView的狀態(tài)為IDLE
if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
setScrollState(SCROLL_STATE_IDLE);
}
} break;
case MotionEvent.ACTION_CANCEL: {
cancelScroll();
} break;
}
return true;
}
將onTouchEvent()
中的代碼進(jìn)行精簡畏铆,只留下處理嵌套滑動(dòng)的部分,整體的邏輯就清晰了起來。這里主要是對(duì)scroll的處理,關(guān)于fling的待會(huì)再看。
① ACTION_DOWN
的時(shí)候RecyclerView調(diào)用startNestedScroll()
方法開始尋找可以進(jìn)行嵌套滑動(dòng)的父View锻离,其實(shí)內(nèi)部就是調(diào)用了NestedScrollingChildHelper
的startNestedScroll()
方法向上尋找最近的實(shí)現(xiàn)了NestedScrollingParent
接口的父View并保存父View的引用疏虫。
② ACTION_MOVE
中執(zhí)行了嵌套滑動(dòng)關(guān)鍵的3步:一是由父View最先消耗滾動(dòng)距離dx
或dy
翅敌;二是子View消耗剩余距離dx - mReusableIntPair[0]
或dy - mReusableIntPair[1]
;三是如果還有滾動(dòng)距離未消耗完,則再交給父Layout消耗饶深。
onTouchEvent()
中進(jìn)行了第1步俱两,第2和第3步的邏輯在scrollByInternal()
方法中:即首先讓RecyclerView自身滾動(dòng),再通過dispatchNestedScroll()
將剩余的距離分發(fā)給父View,源碼精簡后如下。
boolean scrollByInternal(int x, int y, MotionEvent ev) {
int unconsumedX = 0; int unconsumedY = 0;
int consumedX = 0; int consumedY = 0;
if (mAdapter != null) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// RecyclerView本身的滑動(dòng), 最終調(diào)用了LayoutManager的scrollHorizontallyBy()或scrollVerticallyBy()
scrollStep(x, y, mReusableIntPair);
consumedX = mReusableIntPair[0]; consumedY = mReusableIntPair[1];
unconsumedX = x - consumedX; unconsumedY = y - consumedY;
}
// ......
dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
TYPE_TOUCH, mReusableIntPair);
// ......
return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}
③ ACTION_UP
的時(shí)候計(jì)算速度并調(diào)用fling()
方法状蜗。一般來說我們通過Scroller
來實(shí)現(xiàn)慣性滑動(dòng)缸血,在computeScroll()
方法中不斷計(jì)算當(dāng)前的坐標(biāo)并移動(dòng)笆豁。不了解Scroller的可以看參考的[1~3]。
但是實(shí)現(xiàn)了NestedScrollingChild2
接口的View有所不同媒抠,上面提到,這種View的Scroll和Fling事件都可以由dispatchNestedPreScroll()傳遞,由type參數(shù)區(qū)分事件類型苟耻,TYPE_TOUCH
為Scroll事件解虱,TYPE_NON_TOUCH
為Fling事件离咐。
......
是不是感覺怪怪的?按照方法的名字馆铁,dispatchNestedPreScroll()
方法應(yīng)該只傳遞Scroll事件脱衙,而Fling事件由dispatchNestedPreFling()
方法比較合理已旧。確實(shí)璃诀,對(duì)于只實(shí)現(xiàn)了NestedScrollingChild
接口的View就是這么處理的,但是用這種方式傳遞速率比較粗暴犀变,在滑動(dòng)到邊界時(shí)可能存在卡頓現(xiàn)象。而實(shí)現(xiàn)了NestedScrollingChild2
接口的View用了新的方式傳遞Fling事件,來看一下RecyclerView作為子View是怎么傳遞Fling事件給父View的。
public boolean fling(int velocityX, int velocityY) {
// ......
if (!dispatchNestedPreFling(velocityX, velocityY)) {
final boolean canScroll = canScrollHorizontal || canScrollVertical;
dispatchNestedFling(velocityX, velocityY, canScroll);
if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
return true;
}
if (canScroll) {
// ......
startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
mViewFlinger.fling(velocityX, velocityY);
return true;
}
}
return false;
}
代碼中雖然調(diào)用了dispatchNestedPreFling()
和dispatchNestedFling()
方法源梭,但是對(duì)于實(shí)現(xiàn)了NestedScrollingParent2
的父View來說荠卷,對(duì)應(yīng)的回調(diào)方法都不實(shí)現(xiàn)也可以。
我們重點(diǎn)來看下面的mViewFlinger.fling(velocityX, velocityY)
,這句代碼實(shí)現(xiàn)了RecyclerView本身的慣性滑動(dòng)蚁堤,mViewFlinger
是RecyclerView內(nèi)部類ViewFlinger的對(duì)象掂咒。該類精簡后的源碼如下:
class ViewFlinger implements Runnable {
@Override
public void run() {
// ......
final OverScroller scroller = mOverScroller;
if (scroller.computeScrollOffset()) {
// Nested Pre Scroll
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
if (dispatchNestedPreScroll(unconsumedX, unconsumedY, mReusableIntPair, null,
TYPE_NON_TOUCH)) {
unconsumedX -= mReusableIntPair[0];
unconsumedY -= mReusableIntPair[1];
}
// ......
// Nested Post Scroll
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, null,
TYPE_NON_TOUCH, mReusableIntPair);
unconsumedX -= mReusableIntPair[0];
unconsumedY -= mReusableIntPair[1];
// ......
}
void postOnAnimation() {
if (mEatRunOnAnimationRequest) {
mReSchedulePostAnimationCallback = true;
} else {
internalPostOnAnimation();
}
}
private void internalPostOnAnimation() {
removeCallbacks(this);
ViewCompat.postOnAnimation(RecyclerView.this, this);
}
public void fling(int velocityX, int velocityY) {
setScrollState(SCROLL_STATE_SETTLING);
mLastFlingX = mLastFlingY = 0;
// Because you can't define a custom interpolator for flinging, we should make sure we
// reset ourselves back to the teh default interpolator in case a different call
// changed our interpolator.
if (mInterpolator != sQuinticInterpolator) {
mInterpolator = sQuinticInterpolator;
mOverScroller = new OverScroller(getContext(), sQuinticInterpolator);
}
mOverScroller.fling(0, 0, velocityX, velocityY,
Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
postOnAnimation();
}
public void smoothScrollBy(int dx, int dy, int duration,
@Nullable Interpolator interpolator) {
// ......
postOnAnimation();
}
public void stop() {
removeCallbacks(this);
mOverScroller.abortAnimation();
}
}
還記得我們平時(shí)通過Scroller是怎么實(shí)現(xiàn)慣性滑動(dòng)的嗎膝蜈?由于View每次draw()
時(shí)會(huì)調(diào)用computeScroll()
非剃,如果Scroller的滑動(dòng)尚未結(jié)束备绽,就在computeScroll()
中計(jì)算當(dāng)前View應(yīng)該所處的scroll位置并移動(dòng)至該處,最后調(diào)用invalidate()
繼續(xù)觸發(fā)draw()
形成一個(gè)循環(huán)倍靡,直到慣性滑動(dòng)結(jié)束。
RecyclerView實(shí)現(xiàn)慣性滑動(dòng)和Fling事件傳遞的方式與之類似雨让,都是使用Scroller計(jì)算慣性滑動(dòng)的滑動(dòng)距離。但是并沒有重寫computeScroll()
庵寞,那么循環(huán)調(diào)用的機(jī)制是在哪兒實(shí)現(xiàn)的呢?
這里就不得不提postOnAnimation()
的作用了古沥,其內(nèi)部調(diào)用了ViewCompat.postOnAnimation(View, Runnable)
岩齿,它會(huì)將當(dāng)前這個(gè)Runnable對(duì)象post到Choreographer的執(zhí)行隊(duì)列中,等到下一幀到來的時(shí)候會(huì)執(zhí)行該Runnnable
對(duì)象的run()
方法乞封。也就是說菇用,每一幀刷新的時(shí)候都會(huì)通過Scroller計(jì)算這一幀應(yīng)該滑動(dòng)的距離dx
或dy
,然后開啟嵌套滑動(dòng),只不過此時(shí)的type不是TYPE_TOUCH
飞蚓,而是TYPE_NON_TOUCH
,對(duì)于60幀的手機(jī)著榴,一秒會(huì)分發(fā)60次的嵌套滑動(dòng)事件。
1.4 吸頂效果代碼
上面說了這么多问麸,可以發(fā)現(xiàn)RecyclerView本身為嵌套滑動(dòng)做了很多事情,如果以RecyclerView作為嵌套滑動(dòng)的子View哮笆,父View實(shí)現(xiàn)onNestedPreScroll()
就可以實(shí)現(xiàn)初步的嵌套滑動(dòng)效果。想要實(shí)現(xiàn)吸頂效果的代碼启具,我們自定義繼承自LinearLayout的SimpleNestedLinearLayout作為父View并重寫onNestedPreScroll()
方法如下拷沸。
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (dy > 0 && scrollY < imageViewHeight) {
var actualDy = dy
if (scrollY + dy >= imageViewHeight) {
actualDy = imageViewHeight - scrollY
}
consumed[1] = actualDy
scrollBy(0, actualDy)
} else if (dy < 0 && recyclerView?.canScrollVertically(-1) == false && scrollY > 0) {
var actualDy = dy
if (scrollY + dy < 0) {
actualDy = -scrollY
}
consumed[1] = actualDy
scrollBy(0, actualDy)
}
}
這里還需要重寫onMeasure()
方法,因?yàn)長inearLayout本身的measure流程不符合吸頂效果的需求:LinearLayout會(huì)依次measure子View,然后將剩余的高度作為之后子View的最大高度帝嗡,如果這里不重寫onMeasure()
方法,RecyclerView的高度就=(LinearLayout高度-ImageView高度-TextView高度)巢寡,但是RecyclerView的高度應(yīng)該=(父View的高度-TextView高度)。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(
getDefaultSize(suggestedMinimumWidth, widthMeasureSpec),
getDefaultSize(suggestedMinimumHeight, heightMeasureSpec)
)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
for (index in 0 until childCount) {
val child = getChildAt(index) ?: continue
val layoutParams = child.layoutParams
if (layoutParams.height == LayoutParams.MATCH_PARENT) {
val rvHeight = measuredHeight - resources
.getDimensionPixelSize(R.dimen.simple_nested_title_height)
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(rvHeight, heightMode)
measureChild(child, widthMeasureSpec, childHeightMeasureSpec)
} else {
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, heightMode)
measureChild(child, widthMeasureSpec, childHeightMeasureSpec)
}
}
}
剩下的我就不多說了,大家下載源碼查看吧挨稿。
二、WebView與RecyclerView混合布局
2.1 效果展示
WebView與RecyclerView的混合布局經(jīng)常用于新聞APP的新聞頁,其中WebView展示的新聞本身的網(wǎng)頁钉赁,下方RecyclerView負(fù)責(zé)展示相關(guān)推薦、廣告带膜、評(píng)論等內(nèi)容式廷。
2.2 實(shí)現(xiàn)原理
可以發(fā)現(xiàn)這個(gè)父View也是一個(gè)類似LinearLayout的垂直布局,WebView和RecyclerView的高度都與父View相等穗慕。
當(dāng)用戶劃動(dòng)WebView時(shí)逛绵,WebView本身并沒有移動(dòng)瓢对,而是調(diào)用WebView.scrollBy(...)
移動(dòng)WebView里面的內(nèi)容。直到WebView的內(nèi)容滑動(dòng)到底部時(shí)法焰,調(diào)用父View的scrollBy(...)
將WebView和RecyclerView向上移動(dòng)。此時(shí)的布局如下所示,黑色框表示父View傻丝,是用戶的可見區(qū)域。
WebView本身并不支持嵌套滑動(dòng)诉儒,因此我們需要自定義繼承自WebView的NestedScrollWebView葡缰,并重寫它的onTouchEvent()
方法,將它的滑動(dòng)事件向外分發(fā),這部分邏輯參照RecyclerView即可运准,這里不再贅述幌氮,可以參考本文開頭的鏈接胁澳。值得注意的是该互,WebView需要判斷自己的內(nèi)容是否已經(jīng)滑動(dòng)到底部,因此在NestedScrollWebView添加如下方法韭畸。
fun canWebViewScrollDown(): Boolean {
val range = computeVerticalScrollRange()
return ((scrollY + measuredHeight) < range)
}
在WebView與RecyclerView混合布局中宇智,主要關(guān)注父View的邏輯。我們定義一個(gè)繼承自ViewGroup的MixedLayout胰丁,首先重寫它的onMeasure()
方法随橘,把WebView和RecyclerView的高度都設(shè)置為父View的高度,再重寫它的onLayout()
方法锦庸,按照垂直線性布局的方式去排布机蔗。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(
getDefaultSize(suggestedMinimumWidth, widthMeasureSpec),
getDefaultSize(suggestedMinimumHeight, heightMeasureSpec)
)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val height = measuredHeight
for (i in 0 until childCount) {
val child = getChildAt(i) ?: continue
// 其實(shí)WebView的高度不一定為父View的高度, 因?yàn)橛行┒绦侣劦母叨炔蛔阋黄? // 這里為了方便, 假定新聞都是超過一屏的
if (child == webView) {
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode)
measureChild(webView, widthMeasureSpec, childHeightMeasureSpec)
} else if (child == recyclerView) {
val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, heightMode)
measureChild(recyclerView, widthMeasureSpec, childHeightMeasureSpec)
} else {
measureChild(child, widthMeasureSpec, heightMeasureSpec)
}
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child == webView) {
child.layout(0, 0, measuredWidth, measuredHeight)
} else if (child == recyclerView) {
child.layout(0, measuredHeight, measuredWidth, measuredHeight * 2)
}
}
layoutMaxScrollY = measuredHeight
}
下面來看MixedLayout處理嵌套滑動(dòng)的邏輯,首先來看onNestedPreScroll()
方法甘萧。該方法使用actualDy表示實(shí)際移動(dòng)的距離萝嘁,用于處理邊界滑動(dòng)。主要邏輯為判斷當(dāng)前是哪個(gè)View在分發(fā)嵌套滑動(dòng)事件扬卷,再根據(jù)滑動(dòng)方向分別進(jìn)行處理牙言。
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
var actualDy = dy
if (target == webView) {
if (dy > 0 && !webView.canWebViewScrollDown()) {
// WebView內(nèi)容向下滑動(dòng)
if (scrollY + actualDy > layoutMaxScrollY) {
actualDy = layoutMaxScrollY - scrollY
}
scrollBy(0, actualDy)
consumed[1] = actualDy
} else if (dy < 0 && scrollY > 0) {
// WebView內(nèi)容向上滑動(dòng)
if (scrollY + actualDy < 0) {
actualDy = -scrollY
}
scrollBy(0, actualDy)
consumed[1] = actualDy
}
} else if (target == recyclerView) {
if (dy > 0 && scrollY < layoutMaxScrollY) {
if (scrollY + actualDy > layoutMaxScrollY) {
actualDy = layoutMaxScrollY - scrollY
}
scrollBy(0, actualDy)
consumed[1] = actualDy
} else if (dy < 0) {
if (!recyclerView.canScrollVertically(-1)) {
if (scrollY + actualDy < 0) {
actualDy = -scrollY
webView.stopFling()
}
scrollBy(0, actualDy)
consumed[1] = actualDy
}
}
}
}
還有一種情況是在WebView觸發(fā)一個(gè)速度較大的fling,這時(shí)WebView的內(nèi)容會(huì)滑動(dòng)到底部怪得,隨后MixedLayout也會(huì)滑動(dòng)到底部咱枉,最后開始滑動(dòng)RecyclerView。這種情況下的滑動(dòng)事件會(huì)分發(fā)到onNestedScroll()
中進(jìn)行處理徒恋,具體如下所示蚕断。
/**
* RecyclerView的Fling事件傳遞到了WebView 或 WebView的Fling事件傳遞到了RecyclerView
*/
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
if (dyUnconsumed == 0 || nestedScrollAxes != ViewCompat.SCROLL_AXIS_VERTICAL) {
return
}
consumed[1] = dyUnconsumed
if (target == webView && dyUnconsumed > 0) {
if (scrollY >= layoutMaxScrollY) {
if (scrollY > layoutMaxScrollY) {
scrollTo(0, layoutMaxScrollY)
}
if (recyclerView.canScrollVertically(1)) {
recyclerView.scrollBy(0, dyUnconsumed)
} else {
webView.stopNestedScroll(type)
}
}
} else if (target == recyclerView && dyUnconsumed < 0) {
if (scrollY <= 0) {
if (scrollY < 0) {
scrollTo(0, 0)
}
if (webView.scrollY + dyUnconsumed > 0) {
webView.scrollBy(0, dyUnconsumed)
} else {
recyclerView.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
webView.scrollTo(0, 0)
}
}
}
}
三、回彈布局
3.1 效果展示
列表回彈的效果如下所示因谎,在列表滑動(dòng)到邊緣時(shí)可以超過RecyclerView的滑動(dòng)邊界基括,并在用戶松手后回彈至原本的邊界,這種行為也被稱為OverScroll财岔。
3.2 實(shí)現(xiàn)原理
從現(xiàn)象上來看是用戶在滑動(dòng)到RecyclerView的邊界之后還可以多滑動(dòng)一段距離风皿,并在用戶松手時(shí)觸發(fā)回彈,但實(shí)際上實(shí)現(xiàn)OverScroll的不是RecyclerView本身匠璧,而是它的父View桐款,我們不需要對(duì)RecyclerView做任何改變,只需要在它外面套一個(gè)支持OverScroll的BounceLayout即可夷恍。布局如下所示魔眨,黑色框?yàn)锽ounceLayout,藍(lán)色框?yàn)镽ecyclerView。
RecyclerView本身實(shí)現(xiàn)了嵌套滑動(dòng)遏暴,當(dāng)它滑動(dòng)到邊界時(shí)侄刽,經(jīng)常會(huì)產(chǎn)生未消耗的滑動(dòng)距離,也就是dyUnconsumed
朋凉,并通過dispatchNestedScroll(...)
將這段距離分發(fā)給BounceLayout進(jìn)行處理州丹,BounceLayout即可通過scrollBy(...)
滑動(dòng)自己來達(dá)到OverScroll的效果。用戶松手時(shí)RecyclerView會(huì)調(diào)用stopNestedScroll()
杂彭,此時(shí)BounceLayout進(jìn)行回彈即可墓毒。
上面說的是用戶拖動(dòng)RecyclerView時(shí)的情況,在慣性滑動(dòng)下亲怠,如果fling到了邊界所计,那么BounceLayout需要在RecyclerView fling到邊界時(shí)計(jì)算當(dāng)前的速率,根據(jù)速率向外彈出一段距離团秽,最終在速度為0時(shí)回彈主胧。
了解原理之后可以發(fā)現(xiàn)BounceLayout不僅僅可以用于實(shí)現(xiàn)RecyclerView的回彈,任何像RV一樣實(shí)現(xiàn)了嵌套滑動(dòng)子View功能的視圖都可以實(shí)現(xiàn)該功能徙垫,因此這種實(shí)現(xiàn)方式具有很好的解耦性讥裤。下面來看具體實(shí)現(xiàn)。
3.3 具體實(shí)現(xiàn)
3.3.1 最大OverScroll距離
先來討論一下如何限制OverScroll的滑動(dòng)距離姻报,定義當(dāng)前OverScroll的距離為OverScrollDistance,最大可滑動(dòng)距離為MaxOverScrollDistance间螟。假設(shè)當(dāng)前用戶下拉y吴旋,則BounceLayout調(diào)用scrollBy(-y)
使其整體向下移動(dòng),當(dāng)BounceLayout的Math.abs(scrollY)
== MaxOverScrollDistance時(shí)厢破,不管用戶怎么下拉荣瑟,BounceLayout也不該再移動(dòng)了。
上面描述的是OverScrollDistance=scrollY摩泪,也就是線性關(guān)系時(shí)的效果:此時(shí)用戶下拉dy笆焰,BounceLayout移動(dòng)dy。不過如果你使用過OverScroll的功能你就知道见坑,你下拉的距離和BounceLayout移動(dòng)的距離并不是線性關(guān)系:當(dāng)你下拉y時(shí)嚷掠,當(dāng)前OverScrollDistance越大,BounceLayout的實(shí)際移動(dòng)距離就越小荞驴,說得通俗一點(diǎn):當(dāng)前已經(jīng)滑動(dòng)的距離越大不皆,你越難滑動(dòng)它。
想要實(shí)現(xiàn)這樣的效果并不難熊楼,我們?yōu)镺verScrollDistance和scrollY定義一個(gè)插值器OverScrollerInterpolator
:
input = OverScrollDistance/MaxOverScrollDistance
output = scrollY/MaxOverScrollDistance霹娄,公式為:output = (1 - factor ^ (input * 2))
,當(dāng)factor為0.6時(shí),函數(shù)圖如下所示犬耻。
該函數(shù)是先快后慢的效果踩晶,越臨近最大值,用戶越難拖動(dòng)枕磁,這能給用戶帶來較好的體驗(yàn)合瓢。而且不管input多大,output始終<1透典,因此Math.abs(scrollY)
永遠(yuǎn)<MaxOverScrollDistance晴楔。根據(jù)該公式,我們可以定義如下插值器:
inner class OverScrollerInterpolator(private var factor: Float) : Interpolator {
fun getInterpolationBack(input: Float) : Float {
return (ln(1 - input) / ln(factor) / 2)
}
override fun getInterpolation(input: Float): Float {
return (1 - factor.pow(input * 2))
}
}
令x = OverScrollDistance/MaxOverScrollDistance
令y = scrollY/MaxOverScrollDistance
當(dāng)已知x時(shí)峭咒,可以通過getInterpolation()
計(jì)算y税弃,那么已知y時(shí),該怎么計(jì)算x呢凑队?我們來算一下:
y = 1 - factor ^ x* 2
=>x * 2 = log(factor, 1 - y)
=>x * 2 = log(2, 1 - y) / log(2, factor)
=>x = (log(2, 1 - y) / log(2, factor)) / 2
得到的結(jié)果就是getInterpolationBack()
里的算式则果。
至此,我們可以通過OverScrollerInterpolator中的兩個(gè)方法建立scrollY和overScrollDistance之間的函數(shù)關(guān)系漩氨,demo中取factor為0.6西壮,新建插值器如下:
private val mInterpolator: OverScrollerInterpolator = OverScrollerInterpolator(0.6f)
對(duì)于這個(gè)插值器的用法,我們舉個(gè)例子:用戶滑動(dòng)RecyclerView到邊界時(shí)叫惊,BounceLayout可以在onNestedScroll(...)
方法中處理dyUnconsumed款青,如下所示,我們將未消耗的滑動(dòng)距離dyUnconsumed加到mOverScrollDistance并通過mInterpolator的getInterpolation()方法將其轉(zhuǎn)化成scrollY霍狰,再調(diào)用scrollTo()
移動(dòng)到最終的位置抡草。
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
if (type == ViewCompat.TYPE_TOUCH) {
startOverScroll(dyUnconsumed)
} else {
......
}
}
}
private fun startOverScroll(dy: Int) {
updateOverScrollDistance(mOverScrollDistance + dy)
}
private fun updateOverScrollDistance(distance: Int) {
mOverScrollDistance = distance
if (mOverScrollDistance < 0) {
scrollTo(
0, (-mMaxOverScrollDistance * mInterpolator.getInterpolation(
abs(mOverScrollDistance.toFloat() / mOverScrollBorder)
)).toInt()
)
} else {
scrollTo(
0, (mMaxOverScrollDistance * mInterpolator.getInterpolation(
abs(mOverScrollDistance.toFloat() / mOverScrollBorder)
)).toInt()
)
}
}
3.3.2 SpringBack
SpringBack指回彈,表現(xiàn)為將當(dāng)前處于OverScroll狀態(tài)下的BounceLayout恢復(fù)到初始狀態(tài)蔗坯,我們選擇使用ValueAnimator實(shí)現(xiàn)該功能康震,當(dāng)需要回彈時(shí)听盖,調(diào)用startScrollBackAnimator ()
方法即可售睹,相關(guān)代碼如下。
private var mSpringBackAnimator: ValueAnimator? = null
private val mMaxOverScrollDistance = 300
// mOverScrollBorder為mMaxOverScrollDistance的n倍
// 主要用于優(yōu)化滑動(dòng)體驗(yàn)称勋,n越大绘梦,滑動(dòng)阻力越大
private val mOverScrollBorder = mMaxOverScrollDistance * 3
fun startScrollBackAnimator() {
mSpringBackAnimator?.cancel()
mSpringBackAnimator = ValueAnimator.ofInt(mOverScrollDistance, 0)
mSpringBackAnimator?.interpolator = DecelerateInterpolator()
mSpringBackAnimator?.duration = SPRING_BACK_DURATION.toLong()
mSpringBackAnimator?.addUpdateListener { animation ->
updateOverScrollDistance(
animation.animatedValue as Int
)
}
mSpringBackAnimator?.start()
}
private fun updateOverScrollDistance(distance: Int) {
......
}
當(dāng)回彈時(shí)橘忱,建立一個(gè)value從mOverScrollDistance到0的ValueAnimator,更新value時(shí)調(diào)用updateOverScrollDistance()
谚咬,通過mInterpolator將mOverScrollDistance轉(zhuǎn)化成scrollY并移動(dòng)至該位置鹦付。
可以看到實(shí)現(xiàn)回彈效果的邏輯比較簡單,有難度的點(diǎn)在于我們應(yīng)該在什么時(shí)候觸發(fā)回彈择卦。有如下3種場(chǎng)景:
第1種場(chǎng)景: 用戶拖動(dòng)RecyclerView至OverScrollDistance>0后松手敲长。
此時(shí)RecyclerViewACTION_UP
郎嫁,調(diào)用stopNestedScroll()
,回調(diào)至BounceLayout中的onStopNestedScroll()
方法祈噪,在該方法中即可進(jìn)行回彈泽铛。
override fun onStopNestedScroll(target: View, type: Int) {
mNestedScrollingParentHelper.onStopNestedScroll(target)
if (mOverScrollDistance != 0) {
startScrollBackAnimator()
}
}
第2種場(chǎng)景: 用戶拖動(dòng)RecyclerView至OverScrollDistance>0后,再觸發(fā)fling后松手辑鲤。
此時(shí)BounceLayout應(yīng)該再順著fling滑動(dòng)很小一段距離后開始回彈盔腔,我們來看一下代碼實(shí)現(xiàn)。
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
if (mOrientation == ViewCompat.SCROLL_AXIS_VERTICAL && dyUnconsumed != 0) {
if (type == ViewCompat.TYPE_TOUCH) { // 用戶在拖動(dòng)RV
startOverScroll(dyUnconsumed)
} else { // RV在fling狀態(tài)
if (mOverScrollDistance == 0) { // Bounce月褥,下節(jié)說明
mScroller.computeScrollOffset()
startBounceAnimator(mScroller.currVelocity * mLastSign)
} else { // 當(dāng)前場(chǎng)景
startOverScroll(dyUnconsumed) // 順著當(dāng)前fling的方向再滑動(dòng)一小段距離
}
// 讓RecyclerView主動(dòng)停止嵌套滑動(dòng)
ViewCompat.stopNestedScroll(target, type)
}
}
}
可以看到在當(dāng)前場(chǎng)景下弛随,BounceLayout會(huì)再移動(dòng)一小段距離,隨后主動(dòng)調(diào)用ViewCompat.stopNestedScroll(target, type)
宁赤,此時(shí)會(huì)回調(diào)至BounceLayout的onStopNestedScroll(...)
開始回彈舀透。
第3種場(chǎng)景: RecyclerView慣性滑動(dòng)至邊界,BounceLayout根據(jù)當(dāng)前速率外彈出一段距離决左,直到速率為0時(shí)回彈愕够,這種行為就被稱為Bounce。上一段代碼中佛猛,當(dāng)滑動(dòng)到邊界且mOverScrollDistance == 0
時(shí)觸發(fā)Bounce惑芭,具體的邏輯來看下一節(jié)。
3.3.3 Bounce
在上一節(jié)的代碼中我們看到觸發(fā)Bounce的代碼如下:
mScroller.computeScrollOffset()
startBounceAnimator(mScroller.currVelocity * mLastSign)
通過startBounceAnimator()
觸發(fā)Bounce需要初速度和方向继找,我們可以在onNestedPreFling()
中得到RecyclerView慣性滑動(dòng)時(shí)的初速度velocityY和方向mLastSign遂跟。
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
mLastSign = if (velocityY < 0) -1 else 1
mScroller.forceFinished(true)
mScroller.fling(0, 0, 0, velocityY.toInt(), 0,
Int.MAX_VALUE, 0, Int.MAX_VALUE)
return false
}
但是RecyclerView慣性滑動(dòng)的初速度很顯然不等于觸發(fā)Bounce時(shí)的初速度,因此我們通過mScroller.fling()
計(jì)算速度码荔,在滑動(dòng)至邊界時(shí)調(diào)用mScroller.computeScrollOffset()
計(jì)算當(dāng)前時(shí)間點(diǎn)的速度漩勤,再通過mScroller.getCurrVelocity()
即可得到觸發(fā)Bounce時(shí)的初速度。
得到初速度和方向后我們來看看startBounceAnimator(...)做了什么缩搅。
private fun startBounceAnimator(velocity: Float) {
mBounceRunnable?.cancel()
mBounceRunnable = BounceAnimRunnable(velocity, mOverScrollDistance)
mBounceRunnable?.start()
}
該方法啟動(dòng)了BounceAnimRunnable,來看一下它的代碼触幼。在構(gòu)造函數(shù)中硼瓣,首先根據(jù)初速度mVelocity÷減速度mDeceleration計(jì)算duration。啟動(dòng)BounceAnimRunnable后每隔FRAME_TIME毫秒計(jì)算一次當(dāng)前的mOverScrollDistance置谦,當(dāng)duration結(jié)束時(shí)通過startScrollBackAnimator ()
回彈堂鲤。
inner class BounceAnimRunnable : Runnable {
/**
* 兩幀之間的間隔
*/
private var frameInternalMillis = 10
private var mDeceleration = 0
private var mVelocity = 0f
private var mStartY = 0
private var mRuntime = 0
private var mDuration = 0
private var mHasCanceled = false
constructor(velocity: Float, startY: Int) {
mDeceleration = if (velocity < 0) {
BOUNCE_BACK_DECELERATION
} else {-BOUNCE_BACK_DECELERATION
}
mVelocity = velocity
mStartY = startY
mDuration = ((-mVelocity / mDeceleration) * 1000).toInt()
}
fun start() {
postDelayed(this, frameInternalMillis.toLong())
}
fun cancel() {
mHasCanceled = true
removeCallbacks(this)
}
override fun run() {
if (mHasCanceled) {
return
}
mRuntime += frameInternalMillis
val t = mRuntime.toFloat() / 1000
val distance = (mStartY + mVelocity * t + 0.5 * mDeceleration * t * t).toInt()
updateOverScrollDistance(distance)
if (mRuntime < mDuration && abs(distance) < mMaxOverScrollDistance * 2) {
removeCallbacks(this)
postDelayed(this, frameInternalMillis.toLong())
} else {
startScrollBackAnimator()
}
}
}
至此,列表回彈的基本邏輯就講完了媒峡,限于篇幅瘟栖,有些細(xì)節(jié)并未全部列出。完整的源代碼就不貼了谅阿,感興趣的同學(xué)可以去文章開頭的地址下載半哟。