一、問題背景

熟悉雲音樂的同學應該對雲音樂的個人主頁、歌手頁等詳情頁面不陌生,這些詳情頁一般分為上下兩部分,上方頭部部分一般用來展示一些重要信息,下方內容區域一般是多tab結構,每個tab下通常用列表展示詳情一個維度的信息。滑動頭部和下方內容區域都可以讓頁面滾動,頁面上滑時會優先收起頭部部分,懸停標題tab再上滾列表內容,反之亦然。當頭部背景是圖片時還可以下拉放大圖片,方便用戶查看圖片細節。這樣的設計還是很經典的,其他App也會經常看到類似的設計。

為了實現上面的效果,在那個遙遠的還沒有嵌套滾動機制的年代,我們想出瞭如下這種取巧的設計,既不需要複雜的事件攔截與派發,也能保證較好的滑動性能。示意圖如下:

不同於直觀上的上下結構,ViewPage和裡面的ListView是佔滿整個屏幕的,而頭部的header則是覆蓋在ViewPage上面的。同時每個ViewPage裡面的ListView都會添加一個佔位的headView,它的高度和頭部的header一樣高。在listView滾動時使header保持和listView的headView底部對齊,這樣滑動listView時頭部header也會隨著變化,從而實現上面效果。這樣設計還有一個好處是滑動頭部其實相當於滑動下方listView的headView,避免手動攔截頭部header的事件再分發給listView。

當然事物都是有利有弊的,這樣的設計也帶了一些問題。首先是實現缺乏內聚性,為了實現效果需要對每個tab下面的listView添加佔位的headView、設置各種OnScrollChangeListener和OnTouchListener,當然這些可以通過框架封裝統一處理,不過還是不符合我們程序員簡單即美的調性。

其次有一些可以性能優化的地方,我們都知道觸發requestLayout和measure是很多意想不到的性能損耗的主要原因,比如下拉放大圖片時會同時修改listView的headView的高度,會導致ListView不斷的requestLayout,再比如切tab時或者收起頭部head時都會調用listView的setSelectionFromTop方法,該方法內部也會調用requestLayout。

最後為了處理一些特別的場景會存在一些詭異的代碼,比如第一個tab的listView有很多數據,可以一直上滾到頭部隱藏,但第二個tab只有1個數據,它其實是無法上滾的,如果此時從第一個tab切到第二個tab則會出現頭部從隱藏突變到全部出現。為瞭解決這個問題得給每個listView在設置一個佔位的footView,它的高度具體是多少得先測量整個listView的高度是否比屏幕高度下以及小多少。而這個測量過程也會產生額外的measure開銷。

二、嵌套滾動方案及調優

隨著google在Android 5.0開始在View框架中加入了嵌套滾動機制以及在support v4庫中提供了相應的介面,問題迎來了轉機。關於嵌套滾動機制以及NestedScroll相關api在這裡就不贅述,大家感興趣的話可以查看相關api文檔以及相關資料[1]。嵌套滾動機制核心是可以避免傳統的從上到下的事件分發機制只要有一級消費了事件其他層級就不能參與事件消費的尷尬,它提供了一種可能,父級View和子級View共同協商消費滾動事件。關於使用嵌套滾動實現詳情頁的基本效果網上還是有一些實現,比如微信讀書和網易考拉的實現[2][3],大家也可以看一看博採眾長。下面我們開始講解我們的方案實現以及相關的調整優化。

1.初步實現

其實上面一直在講的詳情頁設計的關鍵就是滾動頁面時是先滾頭部還是先滾下面的列表區域,為了讓滾動更絲滑流暢還需要能夠讓一部分先滾再讓另一部分接著滾,這種共同協商消費滾動事件正是嵌套滾動機制的用武之地。首先我們要做的就是自定義我們的AdjustableHeaderLinearLayout作為父級View來實現上面的效果,同時將原來的佈局層級做如下調整。整體佈局變得扁平了,同時也避免了header覆蓋在ViewPager上產生的過度繪製,當然在KitKat之後過度繪製的影響被大幅度削減了,不過我們還是應該儘可能的避免過度繪製。

作為嵌套滑動的父控制項,AdjustableHeaderLinearLayout(後面簡寫AHLLayout)需要實現parent相關的介面。網上非常多的資料還是實現的是NestedScrollingParent介面,但推薦實現的應該是NestedScrollingParent2。因為使用NestedScrollingParent的話,fling操作會變成在onNestedPreFling和onNestedFling進行判斷是否消費的一鎚子買賣,如果父控制項決定不消費fling事件,那麼它後面在子控制項消費了部分fling距離後也不能接著處理剩下的fling距離,導致列表進行fling操作時會出現生硬的中斷,具體可以閱讀這篇文章[4]。

如果滾動下方的RecyclerView(RV)的,相關的調用流程如下: (RV)startNestedScroll → (AHLLayout)onStartNestedScroll → (AHLLayout)onNestedScrollAccepted→ (RV)dispatchNestedPreScroll → (AHLLayout)onNestedPreScroll→ (RV)dispatchNestedScroll→ (AHLLayout)onNestedScroll → (RV)stopNestedScroll → (AHLLayout)onStartNestedScroll。如上RV在自己滾動都會先通過onNestedPreScroll詢問AHLLayout要不消費相應的滾動量以及具體消費多少。所以我們先看最關鍵的onNestedPreScroll方法。

@Override
public void onNestedPreScroll(@NonNull final View target, int dx, int dy, int[] consumed, int type) {
if (dy > 0) { // 向上滾動
if (getScrollY() < mMinHeaderHeight) {
scrollBy(0, dy);
consumed[1] = dy;
}
} else { // 向下滾動
boolean canScrollDown = target.canScrollVertically(-1);
if (!canScrollDown) {
if (getScrollY() > 0) {
scrollBy(0, dy);
consumed[1] = dy;
}
}
}
}

consumed數組表示的parent分別在x,y方向上需要消費多少距離,consumed[1]表示的就是y方向要消費的距離。scrollBy方法會改變View的scrollY從而整體移動View的內容且不會引起View的重新layout和measure,整體移動AHLLayout其實就相當於滾動了header,所以我們用這個來實現上滑頭部的功能。這樣再看實現邏輯還是很清晰的,如果是向上滾的情況,應該先判斷是否需要上滾頭部,向下滾動時先讓RV滾動,如果RV不能滾動時再向下移動header。 還要注意的是通過fling產生的滾動也會通過這個方法先詢問AHLLayout要不要消費滾動距離,我們這裡對兩種滾動採用相關處理。

2.分發頭部的滾動

上面已經實現了基本的效果,不過還有很多細節需要考慮,有時候這些細節比功能的實現還讓人頭疼,就好比功能實現一小時,UI調整大半天。在詳情頁的設計中應該是可以滑動頭部的header的,但在上面的實現中header只是一個普通的ViewGroup,而且它和RV是處於同級的關係,不存在父子關係也沒法傳遞touch事件。當然這裡可以手動攔截各種事件再進行各種處理再進行分發,方法還是有很多,我們這裡選了一個簡單的方案,我們在AHLLayout dispatchTouchEvent接受到Down事件是先判斷是否在頭部header之內,接著在通過後續事件判斷發生了滾動動作時,先補發一個Down事件並將後續事件的y坐標添加一個等於頭部header高度的偏移,這樣正好落在下方的RV的處理範圍等價於滾動了下方的RV,這樣做還有一個好處是隻在確定是滾動動作才hook事件的分發不會喫掉頭部一些按鈕等的點擊事件。 下面是dispatchTouchEvent中的示意代碼:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
mTouchDownOnHeader = ev.getY() < getMaxNeedHideHeight() - getScrollY();
}
if (mTouchDownOnHeader && mNeedHackDispatchTouch) {
return super.dispatchTouchEvent(obtainNewMotionEvent(ev));
}
return super.dispatchTouchEvent(ev);
}

// stickHeaderHeight表示的是懸停的tab的高度,
// minHeaderHeight表示是頭部header的高度
private int getMaxNeedHideHeight() {
return getMinHeaderHeight() - mStickHeaderHeight;
}

下面是判讀是否需要hook dispatchTouchEvent的示意代碼:

final GestureDetector gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return mTouchDownOnHeader;
}

@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (mTouchDownOnHeader) {
mNeedHackDispatchTouch = true;
AdjustableHeaderLinearLayout.this.dispatchTouchEvent(e1);
AdjustableHeaderLinearLayout.this.dispatchTouchEvent(e2);
}
return mTouchDownOnHeader;
}
});
setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}
});

obtainNewMotionEvent方法是通過將接受到的MotionEvent的y坐標增加一個偏移量構造一個新的MotionEvent,這裡有個注意點就是需要考慮多指觸摸的場景,所以要使用對應的MotionEvent.obtain方法構造,不然多指拖動下方的RV時會出現IndexOutOfRangeException。

3.實現下拉放大效果

詳情頁頭部一般都會使用圖片作為背景,通常交互為了用戶更好的使用體驗都會支持下拉放大圖片。以前的方案是下拉時動態調整header的高度,導致不停地重新requestLayout,那有沒有更合理和高效的實現嗎?

我們知道高效移動的關鍵是盡量使用支持直接修改渲染線程的RenderProperty的相關API,比如translation和scale等屬性動畫API,隨著這個思路我們想出瞭如下方案,核心是使用translationY整體下移和使用scale放大頭部的背景圖。

需要注意的是使用scale放大背景View還需要將它的parent也就是header的clipChildren設置為false;如果整體下移的是AHLLayout的話還需要將AHLLayout以及AHLLayout的parent的clipChildren都設為false;如果AHLLayout不動,下移每個child,只需要將AHLLayout的clipChildren設為false。 這種方案非常高效但需要對背景View做一些特別處理,可以選擇暴露相關介面和參數讓使用者去處理對背景View的縮放,最終我們選擇將背景View傳給AHLLayout統一處理,減少重複邏輯和工作量。 示意代碼如下:

...
if (mNeedDragOver && type == ViewCompat.TYPE_TOUCH){
if (getContentTransY() < getMaxDragOverHeight()) {
doOverScroll(getContentTransY() - dy);
}
...

public void setHeaderBackground(View image) {
mHeadBackgroundView = image;
((ViewGroup) mHeaderView).setClipChildren(image == null);
setClipChildren(image == null);
}

private void doOverScroll(float targetTransY) {
if (targetTransY < 0) {
targetTransY = 0;
}
for (int i = 0; i < getChildCount(); i++) {
getChildAt(i).setTranslationY(targetTransY);
}
if (mHeadBackgroundView != null) {
int originHeight = mHeadBackgroundView.getMeasuredHeight();
float scale = (originHeight + targetTransY) * 1f / originHeight;
mHeadBackgroundView.setScaleX(scale);
mHeadBackgroundView.setScaleY(scale);
}
}

4.上滑圖片的視差效果

這裡還有一個細節,原來由於是改變header的高度同時背景圖片用的是centerCrop,所以上滑收起頭部時背景圖片一直顯示的是中間部分,整個過程有一種視差效果,看起來不單調。但新方法上滑其實只是整體移動了內容,背景圖片也會跟著上移,所以我們得想個方案。

遵循上面提到的高效移動原則,我們可以在上滑過程中減小背景圖片的translationY,讓背景圖有個向下運動,與整體的向上運動相抵,看上去背景圖片一直居中顯示。具體的代碼如下所示:

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (mHeaderScrollListener != null) {
if (oldt > 0 && t <=0) {
mHeaderScrollListener.onHeaderTotalShow();
} else if (t == getMaxNeedHideHeight()) {
mHeaderScrollListener.onHeaderTotalHide();
}
mHeaderScrollListener.onScroll(t > 0 ? t : 0);
}
if (t <= 0) {
setHeaderViewPaddingTop(getMaxDragOverHeight() + t);
}
}

final ImageView bg = findViewById(R.id.imageBg);
nestedScrollParentLinearLayout.setHeaderScrollListener(new AdjustableHeaderLinearLayout.HeaderScrollListener() {
@Override
public void onScroll(int dy) {
bg.setTranslationY(dy / 2f);
}
}

5.scrollTo or 其他?

我們知道scrollTo的內部實現會調用postInvalidateOnAnimation,導致AHLLayout會調用它的invalidate方法重新走繪製流程,不過好在頭部相關View也會在繪製流程中有防重入保護。在一次和同事的交流中聊到offsetTopAndBottom系列api,它們和屬性動畫等相關api一樣,都是直接修改渲染線程的RenderProperty實現,可以避免UI線程繪製流程。不過它只能實現一些簡單的效果,同時它不像scrollTo一樣是佈局穩定的,它在每次重新layout的時候會被重置,通常需要配合使用谷歌封裝的ViewOffsetHelper保存一些狀態量。但是沖著它優秀的繪製性能,我們決定還是使用offsetTopAndBottom實現滑動頭部的效果。改造的話其實也不複雜,就是把頭部原來scrollBy改成offsetTopAndBottom實現。

@Override
public void onNestedPreScroll(@NonNull final View target, int dx, int dy, int[] consumed, int type) {
LogUtil.test(" : " + getScrollY());
if (dy > 0) {
if (getTop() > -getMaxNeedHideHeight()) {
consumed[1] = dy;
if (getScrollY() < 0) {
scrollBy(0, dy);
} else {
scrollByOffsetTop(dy);
}
}
} else {
boolean canScrollDown = target.canScrollVertically(-1);
if (!canScrollDown) {
consumed[1] = dy;
if (getTop() < 0) {
scrollByOffsetTop(getTop() - dy > 0 ? getTop() : dy);
} else if (mNeedDragOver && type == ViewCompat.TYPE_TOUCH){
if (getScrollY() > -getMaxDragOverHeight()) {
scrollBy(0, dy);
}
}
}
}
}

scrollByOffsetTop方法做的就是設置AHLLayout的offsetTopAndBottom,同時將onScrollChanged相關的邏輯遷移過來。

private void scrollByOffsetTop(int dy) {
int oldTop = getTop();
int maxNeedHideHeight = getMaxNeedHideHeight();
int newTop = oldTop - dy;
if (newTop < -maxNeedHideHeight) {
dy = maxNeedHideHeight + oldTop;
}
newTop = oldTop - dy;
if (mHeaderScrollListener != null) {
if (oldTop < 0 && newTop >= 0) {
mHeaderScrollListener.onHeaderTotalShow();
} else if (newTop == -maxNeedHideHeight) {
mHeaderScrollListener.onHeaderTotalHide();
}
mHeaderScrollListener.onScroll(-newTop);
}
mOldTop = newTop;
offsetTopAndBottom(-dy);
}

如果這時觸發重新requestLayout的話比如點擊切換tab等,上面設置的offsetTopAndBottom會被重置導致佈局抖動,所以我們在onLayout中還需要簡單處理下。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mOldTop != 0) {
offsetTopAndBottom(mOldTop - t);
}
}

至此我們已經講解了嵌套滾動方案實現原理以及調優的過程,相關demo的示例代碼大家下載查看:note.youdao.com/notesha

三、CoordinateLayout實現方案

回過頭來看,雖然使用AHLLayout可以高效的實現上面的交互效果,但從代碼設計上它還是有問題,它做了太多事情,有太多職責。並且所有的交互邏輯都耦合在它內部,不方便抽象復用。 好在官方在design包中引入了CoordinateLayout,CoordinateLayout通過Behavior給了我們攔截ViewGroup很多處理的機會,包括如何處理子View的測量佈局,如何處理子View的touch事件,如何處理嵌套滾動機制[5]。通過Behavior,我們基本上可以改變和自定義上面所有的行為。CoordinateLayout和官方實現的幾個Behavior都非常優秀,相信大家查看後肯定會受益匪淺,後面有機會可以和大家分享下它們的實現原理。

好了,接下來讓我們看看如何通過CoordinateLayout實現上面的交互效果,相關示例代碼大家可以下載查看note.youdao.com/notesha

1.初步實現

我們這裡並沒有使用經常看到的CoordinatorLayout和AppBarLayout、CollapsingToolbarLayout三連,一是因為官方的默認實現不能完全滿足我們的交互需求,二是使用它們會使得佈局層級較多。

除了這裡根佈局使用的是CoordinatorLayout,其他的佈局和代碼都和正常的沒什麼區別。那ViewPager是如何一直在header的下方的呢?而滾動ViewPager裡面的RV又是如何和header聯動滾動的呢?這一切的關鍵都在定義和使用的BelowHeaderBehavior和HeaderBehavior。同時我們這裡將背景圖從header中取出而放在FrameLayout中,並且也給它綁定了一個HeaderBackgroundBehavior,具體原因後面我們再說。

我們首先看BelowHeaderBehavior,在CoordinatorLayout中無法像其他佈局一樣定義View之間的佈局關係,但我們可以通過Behavior定義子View和哪些子View有依賴關係以及具體的依賴關係。CoordinatorLayout在每個View的位置發生變化時都會遍歷所有的子View,依次調用它綁定的behavior的layoutDependsOn方法判斷是否存在依賴關係,如果存在依賴關係再通過onDependentViewChanged通知被依賴的View。

我們知道下方的ViewPager應該是依賴上方的header,並且始終在header的下方,所以我們如下處理:首先通過看dependency綁定的是否是HeaderBehavior判斷dependency是否是ViewPager想依賴的header。雖然官方判斷依賴關係很多都是直接通過類似dependency instanceof AppBarLayout判斷,但個人感覺還是通過behavior判斷更加解耦一點。 當header位置發生變化,我們同步的移動ViewPager的OffsetTop就可以實現ViewPager始終在header下方的效果。

// BelowHeaderBehavior

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
return behavior != null && behavior instanceof HeaderBehavior;
}

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
ViewCompat.offsetTopAndBottom(child, dependency.getBottom() - child.getTop());
return false;
}

類似的CoordinatorLayout在onMeasure和onLayout也會依次調用每個子View的綁定的behavior的onMeasureChild和layoutChild,我們可以在這裡定義ViewPager的高度以及它的佈局位置。這裡可以直接使用官方寫的HeaderScrollingViewBehavior。這就體現了behavior的優勢,解耦和復用。

同時CoordinatorLayout也完美支持嵌套滾動機制,它自身實現了NestedScrollingParent2介面,並且在相關滾動回調介面中會遍歷所有的子View,並調用它們的behavior相關嵌套滾動的方法,所以CoordinatorLayout讓所有的子View有機會處理嵌套滾動,哪怕這個子View和發生滾動的View一點關係也沒有,厲害吧。 在HeaderBehavior中我們就利用這個特性,方便的實現了當RV發生滾動時先讓header處理和滾動效果。和AHLLayout處理下拉稍微不同,我們這裡下拉時還是移動header的OffsetTop。

// HeaderBehavior

@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
if (dy != 0) {
if (dy > 0) { // 向上滾動
consumed[1] = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), getOverScrollOffset(child));
} else { // 向下滾動
boolean canScrollDown = target.canScrollVertically(-1);
if (!canScrollDown) {
consumed[1] = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), type == ViewCompat.TYPE_NON_TOUCH ? 0 : getOverScrollOffsetchild));
if (consumed[1] == 0 && type == ViewCompat.TYPE_NON_TOUCH) {
((NestedScrollingChild2) target).stopNestedScroll(type);
}
}
}
}
}

當發生下拉時,我們同樣會在滾動停止和ACTION_UP時進行回彈動畫,這裡就要用到behavior攔截onInterceptTouchEvent和onTouchEvent的機制,CoordinatorLayout同樣會在onInterceptTouchEvent和onInterceptTouchEvent遍歷所有的子View,並調用它們的behavior相關方法。比如在HeaderBehavior中我們會攔截UP和Cancel事件,並且判斷是否需要回彈動畫。

// HeaderBehavior

@Override
public boolean onTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) {
...
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
isUpOrCancel = true;
mActivePointerId = INVALID_POINTER;
startRevertAnimator(child);
...
}

至此我們基本實現了詳情頁的交互效果,同時也見識了Behavior的能力,相信我使用Behavior這些能力,再炫酷的交互效果也能輕鬆實現。下圖很好的概況總結了這些能力[6]。

2.分發頭部滾動

頭部滾動的效果我們在AHLLayout方案中是通過hook ViewGroup的dispatchTouchEvent實現的,那通過behavior可以實現嗎?

其實官方已經寫了一個HeaderBehavior,它會攔截header上的touch事件並讓header跟隨手勢移動和fling,對touch事件的攔截和處理非常精彩。和我們的交互效果還是有點類似的,不過唯一的區別是它無法實現在fling時先讓header收起再讓下方的RV繼續fling的滾動,如果不在意這個微小區別的話完全可以使用官方的HeaderBehavior來實現頭部滾動。

但如果還是想實現這種交互效果話其實也很簡單,思路和AHLLayout類似,不過這次換成header攔截滾動事件再分發給下方的ViewPager。我們知道下方ViewPager綁定的behavior是BelowHeaderBehavior,所以可以通過這個找到下方的ViewPager,需要注意的是第一次還需要手動下發一個Down事件才能讓ViewPager接受和處理所有的touch事件。

// HeaderBehavior

if (mIsBeingDragged) {
for (int i = 0, c = parent.getChildCount(); i < c; i++) {
View sibling = parent.getChildAt(i);
final CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) sibling.getLayoutParams()).getBehavior();
if (behavior != null && behavior instanceof BelowHeaderBehavior) {
final int offset = child.getHeight() - child.getBottom();
if (mNeedDispatchDown) {
mNeedDispatchDown = false;
mCurrentDownEvent.offsetLocation(0, offset);
sibling.dispatchTouchEvent(mCurrentDownEvent);
}
ev.offsetLocation(0, offset);
sibling.dispatchTouchEvent(ev);
}
}
}

3.圖片下拉放大和時差效果

在AHLLayout中,為了實現圖片放大效果,我們讓AHLLayout和header的clipChildren都設成false,這樣其實還是有一定風險和留坑的。

這次我們利用CoordinatorLayout的優勢和behavior的便利,將背景圖放在了獨立的FrameLayout並綁定HeaderBackgroundBehavior。我們讓HeaderBackgroundBehavior首先將背景圖的FrameLayout的底部和header的底部始終對齊,再讓FrameLayout的高度增加一個最大下拉放大的高度,最後讓FrameLayout所有的View下移一個最大下拉放大的高度。這也是一個高性能動效很常用的方法,你可以在一開始時給控制項多預留點空間,避免後面反覆調整控制項大小。

// HeaderBackgroundBehavior

@Override
public boolean onMeasureChild(CoordinatorLayout parent, View child,
int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec,
int heightUsed) {
final List<View> dependencies = parent.getDependencies(child);
final View header = findFirstDependency(dependencies);
if (header != null) {
HeaderBehavior headerBehavior = (HeaderBehavior) ((CoordinatorLayout.LayoutParams) header.getLayoutParams()).getBehavior();
final int overScrollOffset = headerBehavior.getOverScrollOffset(header);
child.getLayoutParams().height = header.getMeasuredHeight() + overScrollOffset;
if (child instanceof ViewGroup) {
if (mOriginTransY == -1) {
mOriginTransY = overScrollOffset;
for (int i = 0, c = ((ViewGroup) child).getChildCount(); i < c; i++) {
View view = ((ViewGroup) child).getChildAt(i);
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
layoutParams.height = header.getMeasuredHeight();
layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
view.setTranslationY(overScrollOffset);
}
}
}
return false;
}
return false;
}

@Override
protected void layoutChild(final CoordinatorLayout parent, final View child, final int layoutDirection) {
final List<View> dependencies = parent.getDependencies(child);
final View header = findFirstDependency(dependencies);
final CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
mTempRect1.set(parent.getPaddingLeft() + lp.leftMargin,
header.getBottom()- lp.bottomMargin - child.getMeasuredHeight() + lp.topMargin,
parent.getWidth() - parent.getPaddingRight() - lp.rightMargin,
header.getBottom()- lp.bottomMargin);
child.layout(mTempRect1.left, mTempRect1.top, mTempRect1.right, mTempRect1.bottom);
}

當下拉時HeaderBackgroundBehavior讓FrameLayout隨著header移動,同時放大背景圖片實現放大效果,同時再讓背景圖片上移實現視差效果。

@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
return behavior != null && behavior instanceof HeaderBehavior;
}

@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
ViewCompat.offsetTopAndBottom(child, dependency.getBottom() - child.getBottom());
for (int i = 0, c = ((ViewGroup) child).getChildCount(); i < c; i++) {
final View view = ((ViewGroup) child).getChildAt(i);
final int height = view.getMeasuredHeight();
final float scale = MathUtils.clamp((dependency.getTop() + height * 1f) / height, 1, Integer.MAX_VALUE);
view.setTranslationY(-dependency.getTop() / 2f + mOriginTransY);
view.setScaleX(scale);
view.setScaleY(scale);
}
return super.onDependentViewChanged(parent, child, dependency);
}

至此我們已經講解了CoordinatorLayout方案實現原理。

四、數據和總結

為了測試效果我們特別選了一個比較卡的手機進行測試,打開開發者選項裏的GPU呈現模式分析,分別對改造前改動後以及demo進行滑動測試,測試結果如下圖所示:

我們還可以通過adb shell dumpsys gfxinfo "Package Name" framestats 計算對應的幀率進行定量比較。

可以發現優化的效果還是比較感人的,並且優化後整體幀率都在16ms附近。 除了流暢度的提升外,對代碼結構的優化更加顯著,對頭部以及ViewPager裡面的Fragment都沒有代碼侵入和修改。第一個AHLLayout方案實現簡單輕量些,第二個CoordinatorLayout方案實現更解耦優雅些。 怎麼樣通過上面兩個例子,你是不是覺得實現這樣的動效也很簡單了。

最後總結一下:

1. 遇到複雜的交互效果,優先考慮CoordinatorLayout和嵌套滾動機制,官方源碼真是個大寶藏,很多的實現的機制和原理我們都可以學習借鑒過來優化我們的實現。

2. 佈局移動變化時盡量保證佈局的穩定,避免重新requestLayout,比如可以在一開始時給控制項多預留點空間,避免後面反覆調整控制項大小。

3. 掌握各種動畫方式的性能優先順序,使用最高效的移動方式,比如setOffset系列,translation,scale變換等,這些應該是你設計方案時的首選。

五、參考文獻

[1]:Experimenting with Nested Scrolling. androiddesignpatterns.com

[2]:玩轉Android嵌套滾動. blog.cgsdream.org/2016/

[3]:嵌套滾動設計和源碼分析. juejin.im/post/5ac35b82

[4]:Carry on Scrolling. chris.banes.me/2017/06/

[5]:Intercepting everything with CoordinatorLayout Behaviors. medium.com/androiddevel

[6]:CoordinatorLayout.Behavior Basic. medium.com/@zoha131/coo

推薦閱讀:

相關文章