一、回顧
1.1 色彩
首先來(lái)回顧下之前的問(wèn)題,項(xiàng)目原來(lái)的 UI:

經(jīng)過(guò)一番改造之后變成了這樣:

可以看到列表好看了許多,重要的是各種訂單狀態(tài)有了不同 顏色 作為指示,不同的色彩能帶給用戶(hù)最直觀的感受。
- 綠色:已中標(biāo)訂單
- 黃色:待中標(biāo)訂單
- 紅色:已取消訂單
列表點(diǎn)擊跳轉(zhuǎn)到詳情,這些顏色就可以很好的利用起來(lái)。
1.2 圖標(biāo)
可以看到每條數(shù)據(jù)右上角都有一個(gè)代表當(dāng)前訂單狀態(tài)的小 Chips,而跳轉(zhuǎn)到詳情頁(yè)時(shí),必定也會(huì)有類(lèi)似的文字或圖標(biāo)表示當(dāng)前訂單的狀態(tài)。
這就讓我想到了共享元素動(dòng)畫(huà),或許可以用動(dòng)畫(huà)把列表和詳情兩個(gè)頁(yè)面連接起來(lái)。
效果預(yù)覽
經(jīng)過(guò)一番思考和操作之后,完成了如下效果:

主要涉及的控件和功能有:
- 共享元素動(dòng)畫(huà)
- CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar
接下來(lái)就看具體實(shí)現(xiàn)吧。
二、開(kāi)始
2.1 共享元素動(dòng)畫(huà)
使用共享元素動(dòng)畫(huà),首先需要引入 Material Design 包:
implementation 'com.android.support:design:xxx'
*xxx后綴版本號(hào)最好與項(xiàng)目 targetSdkVersion 版本相同,避免出現(xiàn)適配問(wèn)題,比如 demo 中的targetSdkVersion 28,使用的 design 版本為28.0.0。接著需要指定 Material Theme 相關(guān)主題,因?yàn)?Material 主題只支持 Android 5.0 以上版本,所以需要定義在
values-v21文件夾下style.xml。
同時(shí)需要指定android:windowContentTransitions允許使用 window 內(nèi)容轉(zhuǎn)換動(dòng)畫(huà):
<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<!-- 允許使用transitions -->
<item name="android:windowContentTransitions">true</item>
</style>
Material 系列的主題繼承于 Theme.AppCompat 系列,所以會(huì)有各種熟悉的 style 可供選擇,我們可以根據(jù)實(shí)際情況選擇合適的 style。

- 做好上述準(zhǔn)備工作,就可以開(kāi)始設(shè)置動(dòng)畫(huà)了。首先要確定共享的 View,比如例子中的訂單狀態(tài) TextView,跳轉(zhuǎn)到詳情共享了狀態(tài)圖標(biāo) ImageView。
一般來(lái)說(shuō),共享相同類(lèi)型以及相同內(nèi)容的 View 會(huì)達(dá)到比較好的效果。但是不同類(lèi)型的 View 也是可以共享的,本文中 TextView 與 ImageView 共享雖說(shuō)不太規(guī)范,卻能更好的幫助理解共享元素是針對(duì) View 的動(dòng)畫(huà)轉(zhuǎn)換。
- 設(shè)置 View 的
android:transitionName,這個(gè)是用來(lái)給需要共享的元素作一個(gè)標(biāo)記。既然是標(biāo)記,就需要兩個(gè) View 作相同的標(biāo)記。
Item 布局中的狀態(tài) TextView:
<TextView
android:id="@+id/tv_status"
android:transitionName="rl_offer_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dimen_4"
android:layout_alignParentRight="true"
android:background="@drawable/bg_blue_solid"
tools:text="待中標(biāo)"
android:textColor="@color/white" />
詳情頁(yè)面的狀態(tài) Icon ImageView:
<ImageView
android:transitionName="rl_offer_item"
android:layout_marginLeft="@dimen/dimen_40"
android:layout_centerVertical="true"
android:layout_marginRight="@dimen/dimen_40"
android:id="@+id/iv_status"
android:src="@drawable/img_examine_complete"
android:layout_alignParentRight="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
重要的是兩個(gè) View 都包含一個(gè)共同的 android:transitionName "rl_offer_item",后面跳轉(zhuǎn)會(huì)用到該參數(shù)。
- 進(jìn)行共享元素跳轉(zhuǎn):先判斷當(dāng)前系統(tǒng)版本,大于 Android 5.0 版本進(jìn)行動(dòng)畫(huà)跳轉(zhuǎn)
Intent intent = new Intent(getActivity(),DetailActivity.class);
intent.putExtra(DetailActivity.INTENT_OFFER_BEAN,mOfferAdapter.getItem(position));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
View statusView = view.findViewById(R.id.tv_status);
ActivityOptions options = ActivityOptions
.makeSceneTransitionAnimation(getActivity(), statusView, "rl_offer_item");
startActivity(intent, options.toBundle());
} else {
startActivity(intent);
}
makeSceneTransitionAnimation 方法三個(gè)參數(shù),很好理解:第一個(gè) activity,注意這里是 Activity 并不是 Context。第二個(gè)是要跳轉(zhuǎn)的 View 實(shí)例、最好一個(gè)就是在 xml 中定義的 transitionName "rl_offer_item"。
經(jīng)過(guò)上述步驟就可以實(shí)現(xiàn)一個(gè)簡(jiǎn)單的共享元素動(dòng)畫(huà)。
其它使用方式
如果不喜歡在 xml 中進(jìn)行設(shè)置,可以使用 View.setTransitionName() 方法給 View 設(shè)置 transitionName,不過(guò)要注意是 API 21 以上的:
另外還有更簡(jiǎn)便的 ViewCompat.setTransitionName() 兼容方法來(lái)設(shè)置
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
iv_status.setTransitionName("rl_offer_item");
}
// 或者
ViewCompat.setTransitionName("rl_offer_item");
同樣,在跳轉(zhuǎn)前也可以通過(guò) View.getTransitionName() 或者ViewCompat.getTransitionName() 獲取到當(dāng)前 View 的 transitionName。
更多功能
多個(gè)共享元素跳轉(zhuǎn)
有時(shí)候我們可能需要共享多個(gè)元素(View),讓兩個(gè)頁(yè)面多個(gè)相同的 View 作出類(lèi)似“遷移”的效果,可以這樣做:
Intent intent = new Intent(getActivity(), DetailActivity.class);
intent.putExtra(DetailActivity.INTENT_OFFER_BEAN, mOfferAdapter.getItem(position));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
View statueView = view.findViewById(R.id.tv_status);
View priceView = view.findViewById(R.id.tv_offer);
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(getActivity(),
Pair.create(statueView, ViewCompat.getTransitionName(statueView)),
Pair.create(priceView, ViewCompat.getTransitionName(priceView)));
startActivity(intent, options.toBundle());
} else {
startActivity(intent);
}
可以看到makeSceneTransitionAnimation()方法傳遞的參數(shù)與之前不同,第二個(gè)和第三個(gè)是 Pair 生成的對(duì)象,可以看下 makeSceneTransitionAnimation()方法的重載
public static ActivityOptions makeSceneTransitionAnimation(Activity activity,
Pair<View, String>... sharedElements) {
ActivityOptions opts = new ActivityOptions();
makeSceneTransitionAnimation(activity, activity.getWindow(), opts,
activity.mExitTransitionListener, sharedElements);
return opts;
}
也就是說(shuō),如果有多個(gè)元素進(jìn)行共享,使用 Pair 把 View 和它的 transtionName 綁定,最后逗號(hào)拼接傳遞即可。
- Pair 一個(gè)很簡(jiǎn)單的類(lèi),相當(dāng)于把兩個(gè)對(duì)象綁定起來(lái)合并為一個(gè)方便傳遞。
自定義共享元素動(dòng)畫(huà)(Transtion)模式
如果默認(rèn)的共享元素動(dòng)畫(huà)不滿(mǎn)足需要,還可以自定義,只需在 values-v21下 app 的主 style 指定自定義 Transtion即可:
<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
...
<!-- 定義共享元素動(dòng)畫(huà) transitions -->
<item name="android:windowSharedElementEnterTransition">
@transition/change_image_transform</item>
<item name="android:windowSharedElementExitTransition">
@transition/change_image_transform</item>
</style>
res/transition/change_image_transform.xml
<?xml version="1.0" encoding="utf-8"?>
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android">
<changeImageTransform />
</transitionSet>
changeImageTransform只是其中一種,可以在系統(tǒng)提供的多種 transitionSet 中自己選擇,也可以組合一個(gè) transtionSet。

2.2 詳情折疊 View
先來(lái)看一下詳情頁(yè)面的整體效果:

布局文件
activity_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<android.support.design.widget.AppBarLayout
android:id="@+id/app_bar"
android:background="@null"
android:layout_width="match_parent"
android:layout_height="200dp">
<android.support.design.widget.CollapsingToolbarLayout
android:id="@+id/collapsing_toolbar_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:theme="@style/ThemeOverlay.AppCompat.Dark"
app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<RelativeLayout
android:id="@+id/rl_top_bg"
app:layout_collapseMode="parallax"
app:layout_collapseParallaxMultiplier="0.75"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:layout_marginLeft="@dimen/dimen_40"
android:transitionName="rl_offer_item"
android:layout_centerVertical="true"
android:layout_marginRight="@dimen/dimen_40"
android:id="@+id/iv_status"
android:src="@drawable/img_examine_complete"
android:layout_alignParentRight="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RelativeLayout>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar_detail"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"/>
</android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
<android.support.v4.widget.NestedScrollView
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_marginTop="@dimen/dimen_1"
android:orientation="vertical"
android:paddingBottom="@dimen/dimen_10"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_line"
android:padding="16dp"
android:transitionName="offer_line_name"
android:layout_marginTop="@dimen/dimen_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:textColor="@color/text_main_black"
android:textSize="@dimen/sp_16"
tools:text="北京 朝陽(yáng) -- 上海 青浦陽(yáng) -- 上海 青浦陽(yáng) -- 上海 青浦" />
<TextView
android:id="@+id/tv_price"
android:transitionName="detail_price"
android:padding="16dp"
android:layout_below="@+id/tv_line"
android:layout_marginTop="@dimen/dimen_2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:textSize="@dimen/sp_16"
android:textColor="@color/text_main_black"
tools:text="報(bào)價(jià):2000元" />
<!--省略一些布局-->
</LinearLayout>
</android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>
這就是之前提到過(guò)的 CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 組合,看上去比較唬人,我們慢慢看:
官方文檔對(duì)它的描述:
- 作為某個(gè)頁(yè)面的根布局(xml 中類(lèi)似 LinearLayout 等的頂級(jí)布局);
- 作為一個(gè)容器:其中一個(gè)或多個(gè) View 有特殊相互作用。
使用:
- 通過(guò)定義子 View 的 Behaviors 來(lái)確定子 View 直接的聯(lián)系,比如可以設(shè)置 A View 滑動(dòng)的時(shí)候,B View 也跟著滑動(dòng)。
比如上面的例子,當(dāng) NestedScrollView 向上滑動(dòng)時(shí),會(huì)通過(guò)回調(diào)方法告知父 View 也就是 CoordinatorLayout 滑動(dòng)的距離。
CoordinatorLayout再遍歷所有子 View,拿到子 View 設(shè)置的 Behavior,通過(guò) Behavior 可以告知 AppBarLayout 滑動(dòng)偏移的距離,完成滑動(dòng)。
2. AppBarLayout
官網(wǎng)描述:
- 一個(gè)垂直的 LinearLayout,MaterialDesign 設(shè)計(jì)導(dǎo)航欄的實(shí)現(xiàn)
使用:
- 子 View 需要設(shè)置
app:layout_scrollFlags或 setScrollFlags(int) 來(lái)確定想實(shí)現(xiàn)的滑動(dòng)效果;- 該 View 嚴(yán)重依賴(lài)于 CoordinatorLayout,也就是說(shuō)要使用 CoordinatorLayout 作為其父布局,不然無(wú)法實(shí)現(xiàn)大部分功能和效果;
- 通過(guò)給另外一個(gè) View 設(shè)置 AppBarLayout.ScrollingViewBehavior 來(lái)確定 AppBarLayout 何時(shí)滑動(dòng)。
根據(jù)特性描述,結(jié)合上文的詳情頁(yè)面布局,寫(xiě)一個(gè)省略版的:
<!--外層需要 CoordinatorLayout-->
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.v7.widget.Toolbar
...
<!--AppBarLayout 子 View 設(shè)置滑動(dòng) Flags-->
app:layout_scrollFlags="scroll|enterAlways"/>
</android.support.design.widget.AppBarLayout>
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
<!-- NestedScrollView 就是與 AppBarLayout 配合的 View,設(shè)置
app:layout_behavior 來(lái)確定-->
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- Your scrolling content -->
</android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>
要注意的是:
- 最外層布局為
CoordinatorLayout以發(fā)揮AppBarLayout大部分效果; -
AppBarLayout的子布局(例子是 Toolbar)設(shè)置 app:layout_scrollFlags,注意 Toolbar 的app:layout_scrollFlags
scroll 表示子 View 跟隨滾動(dòng)(就像 RecyclerView 添加 Header)。
enterAlways 表示總是最先出現(xiàn),當(dāng) Toolbar 向上滑出屏幕,手指下滑時(shí),Toolbar 優(yōu)先滑動(dòng)出來(lái)。等到 Toolbar 展示完畢,再由其它 View 接收滑動(dòng)事件(例子中的 NestedScrollView 接著滑動(dòng))。
這里再記錄下其它三個(gè) Flags:
enterAlwaysCollapsed 表示最先出現(xiàn),直至最小高度。等到最小高度展示完畢,NestedScrollView 進(jìn)行滑動(dòng),完畢后再接著滑動(dòng) Toolbar 到最大高度。
exitUntilCollapsed View 向上滾動(dòng)時(shí),跟隨縮短至最小高度。然后不再變化,保留在屏幕頂端。上文詳情頁(yè)例子用到了這個(gè)效果
snap 像一個(gè)吸附效果。滑動(dòng)完畢松開(kāi)手指,要么滑動(dòng)出屏幕,要么保留在頁(yè)面中。
- NestedScrollView 設(shè)置 app:layout_behavior,上文提到過(guò)
CoordinatorLayout會(huì)遍歷所有子 View 獲取其 Behavior,就是這里設(shè)置的 app:layout_behavior。
這里使用的 Behavior 是appbar_scrolling_view_behavior,這對(duì)應(yīng)著 AppBarLayout 的一個(gè)靜態(tài)內(nèi)部類(lèi)ScrollingViewBehavior。到這里一些部件就湊齊了:
NestedScrollView滑動(dòng),回調(diào)方法給CoordinatorLayout,CoordinatorLayout再通過(guò) Behavior 把要滑動(dòng)的距離等參數(shù)傳遞,最后 AppBarLayout 的ScrollingViewBehavior起到一個(gè)更新 AppBarLayout 的作用。
官網(wǎng)描述:
CollapsingToolbarLayout 用來(lái)實(shí)現(xiàn)一個(gè)可折疊的應(yīng)用程序工具欄,它被設(shè)計(jì)作為 AppBarLayout 的直接子View。
特點(diǎn):
- Collapsing title:可跟隨滑動(dòng)發(fā)生大小以及位置變化的標(biāo)題,可以通過(guò) xml
app:title=""設(shè)置,也可以通過(guò)代碼 setTitle(CharSequence) 設(shè)置。優(yōu)先級(jí)高于 Toolbar 設(shè)置的標(biāo)題;- Content scrim:內(nèi)容遮罩xml
app:contentScrim=""/setContentScrim(Drawable)
設(shè)置,相當(dāng)于給 CollapsingToolbarLayout 設(shè)置一個(gè)增強(qiáng)版的 background,該 background 會(huì)跟隨滑動(dòng)發(fā)生例如透明度等的變化;- Status bar scrim:狀態(tài)欄遮罩xml
app:statusBarScrim=""/setStatusBarScrim(Drawable)
設(shè)置,CollapsingToolbarLayout 折疊時(shí)狀態(tài)欄顏色背景等,需要在 LOLLIPOP 且設(shè)置android:fitsSystemWindows="true";- Parallax scrolling children:視差系數(shù) xml
app:layout_collapseParallaxMultiplier="",取值在 0-1.0 之間。- Pinned position children:子 View 可以選擇全局固定在空間中,比如給 Toolbar 設(shè)置 xml
app:layout_collapseMode="pin"表示固定在頂部不跟隨移動(dòng)、app:layout_collapseMode="parallax"表示跟隨 CollapsingToolbarLayout 進(jìn)行視差移動(dòng)。
簡(jiǎn)單記錄一下實(shí)現(xiàn)原理,AppbarLayout 維護(hù)了一個(gè)List List<AppBarLayout.BaseOnOffsetChangedListener> listeners 保存了所有監(jiān)聽(tīng)。在 AppbarLayout 進(jìn)行偏移,比如高度變化時(shí),遍歷通知這些 listener。
當(dāng)然 CollapsingToolbarLayout 內(nèi)部有一個(gè) OffsetUpdateListener 就是實(shí)現(xiàn)于 BaseOnOffsetChangedListener 的,在 CollapsingToolbarLayout 初始化時(shí)會(huì)調(diào)用 AppbarLayout 的方法把自己的 listener 添加到 AppbarLayout 維護(hù)的監(jiān)聽(tīng)列表里。 所以在AppbarLayout發(fā)生變化時(shí),CollapsingToolbarLayout會(huì)收到通知。
CollapsingToolbarLayout 內(nèi)的 Listener 收到通知時(shí),再改變自己 View 的狀態(tài),比如子 View 的展示與隱藏,透明度的變化等。這樣上面例子中的變化效果就可以理解了。
像一個(gè) ScrollView,但是支持嵌套滾動(dòng)。
官方文檔也沒(méi)有太多的介紹,接下來(lái)看源碼吧:
NestedScrollView 實(shí)現(xiàn)了兩個(gè)接口:NestedScrollingParent2 NestedScrollingChild2,分別用于作為父布局和子布局處理滑動(dòng)事件。CoordinatorLayout 只實(shí)現(xiàn)了 NestedScrollingParent2 接口,說(shuō)明它只支持作為父布局處理嵌套滑動(dòng)。
NestedScrollingParent2
public interface NestedScrollingParent2 extends NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View var1, @NonNull View var2, int var3, int var4);
void onNestedScrollAccepted(@NonNull View var1, @NonNull View var2, int var3, int var4);
void onStopNestedScroll(@NonNull View var1, int var2);
void onNestedScroll(@NonNull View var1, int var2, int var3, int var4, int var5, int var6);
void onNestedPreScroll(@NonNull View var1, int var2, int var3, @NonNull int[] var4, int var5);
}
在上文 1. CoordinatorLayout 中我們提到過(guò),NestedScrollView通過(guò)回調(diào)方法告知父 View,就是通過(guò)遍歷 NestedScrollView 的父 View,如果它們 instanceof NestedScrollingParent2,就調(diào)用相關(guān)接口方法傳遞信息。我們主要關(guān)注滑動(dòng)事件,接下來(lái)看 NestedScrollView 收到點(diǎn)擊事件之后的源碼:
NestedScrollView # onTouch()
public boolean onTouchEvent(MotionEvent ev) {
...
switch (actionMasked) {
case MotionEvent.ACTION_DOWN: {
...
break;
}
case MotionEvent.ACTION_MOVE:
...
// deltaY:垂直移動(dòng)的距離,deltaY = 上一次y值 - 當(dāng)前y值
int deltaY = mLastMotionY - y;
// 子 view 準(zhǔn)備滑動(dòng),通知父控件
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
// 父控件消費(fèi)了mScrollConsumed[1],子 view 還剩下 deltaY 距離可以消費(fèi)
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
...
// 在拖動(dòng)狀態(tài)下
if (mIsBeingDragged) {
...
// 子 view 消費(fèi)滑動(dòng)事件后,將消費(fèi)距離詳情通知父控件
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
...
}
break;
case MotionEvent.ACTION_UP:
...
break;
case MotionEvent.ACTION_CANCEL:
...
break;
...
}
...
return true;
}
拿到手指滑動(dòng)的距離 deltaY 之后調(diào)用內(nèi)部方法通知父控件:
NestedScrollView # dispatchNestedPreScroll()
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow, int type) {
return this.mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
}
- mChildHelper 是 NestedScrollingChildHelper 類(lèi)的實(shí)例,這個(gè)類(lèi)主要幫助處理當(dāng)前 View 作為嵌套滑動(dòng)子 View 時(shí)的處理,這里看下 mChildHelper 的
dispatchNestedPreScroll()方法做了啥
NestedScrollingChildHelper # dispatchNestedPreScroll()
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type) {
// 如果開(kāi)啟嵌套滑動(dòng),默認(rèn)開(kāi)啟
if (isNestedScrollingEnabled()) {
final ViewParent parent = getNestedScrollingParentForType(type);
if (parent == null) {
return false;
}
// 如果存在滑動(dòng)距離
if (dx != 0 || dy != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
// 數(shù)組 consumed 用來(lái)記錄消耗的滑動(dòng)距離,第一個(gè)元素 x 軸(水平滑動(dòng)距離),第二個(gè) y軸(垂直)
if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
// 傳遞數(shù)據(jù)
ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
if (offsetInWindow != null) {
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
這個(gè)方法主要做了三件事:
- 拿到 ViewParent,也就是父 View;
- 判斷如果存在滑動(dòng)距離,調(diào)用
ViewParentCompat.onNestedPreScroll()將距離等參數(shù)傳遞給父 View 處理; - 返回結(jié)果:父 View 是否消耗了滑動(dòng)數(shù)據(jù)。
這里主要看這個(gè) Helper 是怎么把數(shù)據(jù)傳遞給父 View,也就是 CoordinatorLayout 的:
ViewParentCompat#onNestedPreScroll
public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
int[] consumed, int type) {
if (parent instanceof NestedScrollingParent2) {
// First try the NestedScrollingParent2 API
((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, type);
} else if (type == ViewCompat.TYPE_TOUCH) {
// Else if the type is the default (touch), try the NestedScrollingParent API
IMPL.onNestedPreScroll(parent, target, dx, dy, consumed);
}
}
可以看到,如果父 View 實(shí)現(xiàn)了 NestedScrollingParent2 接口,就調(diào)用它的 onNestedPreScroll() 方法,將滑動(dòng)參數(shù)交個(gè)父 View 處理。
由于例子中 NestedScrollView 的父 View 是 CoordinatorLayout,我們就來(lái)看下 CoordinatorLayout 中的 onNestedPreScroll() 方法是怎么實(shí)現(xiàn)的:
CoordinatorLayout#onNestedPreScroll()
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
int xConsumed = 0;
int yConsumed = 0;
// 標(biāo)記是否接受/消費(fèi)這次事件
boolean accepted = false;
int childCount = this.getChildCount();
for(int i = 0; i < childCount; ++i) {
View view = this.getChildAt(i);
if (view.getVisibility() != 8) {
CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams)view.getLayoutParams();
if (lp.isNestedScrollAccepted(type)) {
// 拿到子 View 設(shè)置的 Behavior
CoordinatorLayout.Behavior viewBehavior = lp.getBehavior();
if (viewBehavior != null) {
this.mTempIntPair[0] = this.mTempIntPair[1] = 0;
// 調(diào)用子 View 的 onNestedPreScroll 消費(fèi)事件
viewBehavior.onNestedPreScroll(this, view, target, dx, dy, this.mTempIntPair, type);
xConsumed = dx > 0 ? Math.max(xConsumed, this.mTempIntPair[0]) : Math.min(xConsumed, this.mTempIntPair[0]);
yConsumed = dy > 0 ? Math.max(yConsumed, this.mTempIntPair[1]) : Math.min(yConsumed, this.mTempIntPair[1]);
accepted = true;
}
}
}
}
consumed[0] = xConsumed;
consumed[1] = yConsumed;
if (accepted) {
this.onChildViewsChanged(1);
}
}
- 定義一個(gè)標(biāo)記表示是否接收或者說(shuō)消費(fèi)滑動(dòng)事件 accepted;
- 遍歷子 View,拿到其 Behavior,調(diào)用該 Behavior 的
onNestedPreScroll()方法處理滑動(dòng)事件。既然是遍歷,就來(lái)挨個(gè)看一下例子中我們?cè)O(shè)置的 Behavior:-
AppBarLayout是注解方式指定的@DefaultBehavior(AppBarLayout.Behavior.class); -
NestScrollView是 xml 中指定的appbar_scrolling_view_behavior,對(duì)應(yīng)的是AppbarLayout的一個(gè)靜態(tài)內(nèi)部類(lèi)ScrollingViewBehavior。
-
首先來(lái)看 NestScrollView 指定的 ScrollingViewBehavior 中的 onNestedPreScroll() 方法,最后發(fā)現(xiàn)只調(diào)用了頂級(jí)父類(lèi) CoordinatorLayout.Behavior 的空方法 onNestedPreScroll(),所以這里不必理會(huì)。
那么接著來(lái)看另一個(gè)子 View AppBarLayout 的 onNestedPreScroll() 方法,所以上文說(shuō) NestScrollView的滑動(dòng)會(huì)影響 AppBarLayout的高度,就是因?yàn)檫@里調(diào)用了 AppBarLayout 設(shè)置的 Behavior 來(lái)改變 AppBarLayout 的高度。
AppBarLayout 設(shè)置的 AppBarLayout.Behavior.class 并沒(méi)有定義 onNestedPreScroll(),所以看這個(gè) Behavior 的父類(lèi):
AppBarLayout.BaseBehavior # onNestedPreScroll()
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, T child, View target, int dx, int dy, int[] consumed, int type) {
if (dy != 0) {
...
if (min != max) {
consumed[1] = this.scroll(coordinatorLayout, child, dy, min, max);
this.stopNestedScrollIfNeeded(dy, child, target, type);
}
}
}
跳過(guò)一些細(xì)節(jié),了解到調(diào)用了 scroll() 方法執(zhí)行后面的邏輯。注意下這里的 consumed[1],它是經(jīng)過(guò)層層傳遞而來(lái)的,用來(lái)記錄消耗的滑動(dòng)距離的數(shù)組,consumed[1] 表示垂直滑動(dòng)距離...
可以猜想到 scroll() 方法就是進(jìn)行滑動(dòng)的重要方法,該方法又是由 BaseBehavior 的父類(lèi) HeaderBehavior 實(shí)現(xiàn)的:
HeaderBehavior#scroll()
final int scroll(CoordinatorLayout coordinatorLayout, V header, int dy, int minOffset, int maxOffset) {
return this.setHeaderTopBottomOffset(coordinatorLayout, header, this.getTopBottomOffsetForScrollingSibling() - dy, minOffset, maxOffset);
}
int setHeaderTopBottomOffset(CoordinatorLayout parent, V header, int newOffset, int minOffset, int maxOffset) {
int curOffset = this.getTopAndBottomOffset();
int consumed = 0;
if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
// 新的偏移量如果小于 minOffset 則等于minOffset ,如果大于 maxOffset 則等于 maxOffset
newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
if (curOffset != newOffset) {
this.setTopAndBottomOffset(newOffset);
consumed = curOffset - newOffset;
}
}
return consumed;
}
經(jīng)過(guò)了一系列操作,我們最后終于得到了 consumed,也就是父 View 消耗的距離。最后會(huì)把消耗的距離返回給 NestedScrollView,NestedScrollView 拿到父 View 消費(fèi)的距離,可以計(jì)算出剩下可滑動(dòng)距離用于自己滑動(dòng)事件的處理。
這個(gè)方法最后返回了父 View 消費(fèi)的距離,嚴(yán)格來(lái)說(shuō),是父 View 把數(shù)據(jù)交給 Behavior 消費(fèi)了。具體是怎么處理的呢,再看一下 setHeaderTopBottomOffset 的具體實(shí)現(xiàn):
首先拿到當(dāng)前 View 距離頂部的偏移量,如果 minOffset 不等于 0 且大于等于 minOffset 且小于等于 maxOffset ,則進(jìn)行滑動(dòng)事件消費(fèi),這里可以理解為該 View 的高度在最大高度和最小高度之間才進(jìn)行滑動(dòng)。接下來(lái)就是進(jìn)行滑動(dòng)了:
關(guān)鍵代碼就在上面 this.setTopAndBottomOffset(newOffset)。這個(gè)方法是又是由 HeaderBehavior 的父類(lèi)ViewOffsetBehavior實(shí)現(xiàn)的:
ViewOffsetBehavior#setTopAndBottomOffset
public boolean setTopAndBottomOffset(int offset) {
if (this.viewOffsetHelper != null) {
return this.viewOffsetHelper.setTopAndBottomOffset(offset);
} else {
this.tempTopBottomOffset = offset;
return false;
}
}
這里又用了 ViewOffsetHelper 來(lái)更改 View 的頂部和底部的偏移量,this.viewOffsetHelper.setTopAndBottomOffset(offset) 這個(gè)方法最后會(huì)調(diào)用 View 的 invalidate() 方法。有了數(shù)據(jù)、有了重繪,最終改變 View 的屬性,這個(gè)過(guò)程不再贅述了。
到這里,NestedScrollView 收到手指滑動(dòng)事件的一部分操作才算完成,說(shuō)了這么多在 NestedScrollView 的代碼中進(jìn)行了一行(#笑哭),回過(guò)頭來(lái)看看:
public boolean onTouchEvent(MotionEvent ev) {
...
switch (actionMasked) {
case MotionEvent.ACTION_DOWN: {
...
break;
}
case MotionEvent.ACTION_MOVE:
...
// deltaY:垂直移動(dòng)的距離,deltaY = 上一次y值 - 當(dāng)前y值
int deltaY = mLastMotionY - y;
// 子 view 準(zhǔn)備滑動(dòng),通知父控件
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
// 父控件消費(fèi)了mScrollConsumed[1],子 view 還剩下 deltaY 距離可以消費(fèi)
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
...
// 在拖動(dòng)狀態(tài)下
if (mIsBeingDragged) {
...
// 自己滑動(dòng)剩下的距離
if (this.overScrollByCompat(0, deltaY, 0, this.getScrollY(), 0, range, 0, 0, true) && !this.hasNestedScrollingParent(0)) {
this.mVelocityTracker.clear();
}
// 子 view 消費(fèi)滑動(dòng)事件后,將消費(fèi)距離詳情通知父控件
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
...
}
break;
case MotionEvent.ACTION_UP:
...
break;
case MotionEvent.ACTION_CANCEL:
...
break;
...
}
...
return true;
}
就是這個(gè) dispatchNestedPreScroll() 方法執(zhí)行了一大串邏輯,我們?cè)俸?jiǎn)單總結(jié)下:
- dispatchNestedPreScroll 方法傳遞滑動(dòng)距離,找到實(shí)現(xiàn)了
NestedScrollingParent2接口的父 View,也就是CoordinatorLayout; - 調(diào)用
CoordinatorLayout的onNestedPreScroll方法,讓父 View 消費(fèi)滑動(dòng)事件; - 父 View
CoordinatorLayout遍歷獲取子 View 設(shè)置的 Behavior,然后調(diào)用這個(gè) Behavior 的onNestedPreScroll()方法去滑動(dòng)子 View; - 子 View 滑動(dòng)完成之后,返回未滑動(dòng)剩余的距離,再由父 View
CoordinatorLayout返回給NestedScrollView。 -
NestedScrollView拿到未消費(fèi)的距離,自己經(jīng)過(guò)滑動(dòng)之后,再把剩下的距離交給 父 ViewCoordinatorLayout處理。就是上面的dispatchNestedScroll()方法。本文就不在分析了...
到這里就對(duì)整個(gè)流程有了一個(gè)大概的了解,看懂了這一塊的流程,其它的應(yīng)該會(huì)比較好理解了。
三、總結(jié)
Material Design 已經(jīng)推出好多年了,雖然國(guó)內(nèi) app 使用該設(shè)計(jì)思想的少之又少,但就我個(gè)人來(lái)說(shuō)還是比較喜歡的,所以會(huì)盡量在自己的項(xiàng)目應(yīng)用該設(shè)計(jì)思想。
共享元素動(dòng)畫(huà): 使用需要靈活。和 CardView 一樣,效果雖好,不可在一個(gè)項(xiàng)目中過(guò)多使用。
CoordinatorLayout: 協(xié)調(diào)者布局,子 View 滑動(dòng)時(shí)通知 CoordinatorLayout、CoordinatorLayout 再通過(guò)其它子 View 設(shè)置的 Behaviors 促成滑動(dòng)或其它效果。
AppBarLayout: app bar 的 MD 實(shí)現(xiàn),配合父 View CoordinatorLayout 以及其它同級(jí) View 的 Behaviors 可以實(shí)現(xiàn)滑動(dòng)聯(lián)動(dòng)效果。
由于 AppBarLayout 是一個(gè)垂直的 LinearLayout,我們也可以在其內(nèi)按照順序放置其它 View。比如在上面例子中的 CollapsingToolbarLayout 底部添加 TabLayout,NestedScrollView 替換成 ViewPager 同時(shí)設(shè)置想要的app:layout_behavior,就可以實(shí)現(xiàn)一個(gè) TabLayout+ViewPager 的組合。CollapsingToolbarLayout: 根據(jù)推薦父 View AppBarLayout 的滑動(dòng),可以實(shí)現(xiàn)各種比如透明度、縮放的效果。
NestedScrollView 實(shí)現(xiàn)了嵌套滑動(dòng)的 ScrollView。通過(guò)接口方法可以告知父 View 或子 View 自己滑動(dòng)的距離,實(shí)現(xiàn)嵌套滑動(dòng)。
與 AppBarLayout 協(xié)作的不僅限于 NestedScrollView,也可以是 RecyclerView 或其它,只要指定好與 AppBarLayout 協(xié)作的 Behaviors 就可以。
以上就是本文全部?jī)?nèi)容,如果錯(cuò)誤或分析不恰當(dāng)之處望指出,感謝!