Android 項(xiàng)目優(yōu)化筆記(五):實(shí)現(xiàn)一個 MD 風(fēng)格詳情頁

一奶陈、回顧

前文索引:
Android 項(xiàng)目優(yōu)化筆記(一):概覽

1.1 色彩

首先來回顧下之前的問題怯屉,項(xiàng)目原來的 UI:


詢價

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

詢價改

可以看到列表好看了許多蔚舀,重要的是各種訂單狀態(tài)有了不同 顏色 作為指示,不同的色彩能帶給用戶最直觀的感受锨络。

  • 綠色:已中標(biāo)訂單
  • 黃色:待中標(biāo)訂單
  • 紅色:已取消訂單

列表點(diǎn)擊跳轉(zhuǎn)到詳情赌躺,這些顏色就可以很好的利用起來。

1.2 圖標(biāo)

可以看到每條數(shù)據(jù)右上角都有一個代表當(dāng)前訂單狀態(tài)的小 Chips羡儿,而跳轉(zhuǎn)到詳情頁時礼患,必定也會有類似的文字或圖標(biāo)表示當(dāng)前訂單的狀態(tài)。

這就讓我想到了共享元素動畫掠归,或許可以用動畫把列表和詳情兩個頁面連接起來缅叠。

效果預(yù)覽

經(jīng)過一番思考和操作之后,完成了如下效果:


整體效果

主要涉及的控件和功能有:

  • 共享元素動畫
  • CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar

接下來就看具體實(shí)現(xiàn)吧虏冻。

二痪署、開始

2.1 共享元素動畫

  1. 使用共享元素動畫,首先需要引入 Material Design 包
    implementation 'com.android.support:design:xxx'
    *xxx后綴版本號最好與項(xiàng)目 targetSdkVersion 版本相同兄旬,避免出現(xiàn)適配問題,比如 demo 中的 targetSdkVersion 28,使用的 design 版本為 28.0.0领铐。

  2. 接著需要指定 Material Theme 相關(guān)主題悯森,因?yàn)?Material 主題只支持 Android 5.0 以上版本,所以需要定義在 values-v21 文件夾下 style.xml绪撵。
    同時需要指定 android:windowContentTransitions 允許使用 window 內(nèi)容轉(zhuǎn)換動畫:

<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 系列瓢姻,所以會有各種熟悉的 style 可供選擇,我們可以根據(jù)實(shí)際情況選擇合適的 style音诈。

MaterialComponents

  1. 做好上述準(zhǔn)備工作幻碱,就可以開始設(shè)置動畫了。首先要確定共享的 View细溅,比如例子中的訂單狀態(tài) TextView褥傍,跳轉(zhuǎn)到詳情共享了狀態(tài)圖標(biāo) ImageView。

一般來說喇聊,共享相同類型以及相同內(nèi)容的 View 會達(dá)到比較好的效果恍风。但是不同類型的 View 也是可以共享的,本文中 TextView 與 ImageView 共享雖說不太規(guī)范誓篱,卻能更好的幫助理解共享元素是針對 View 的動畫轉(zhuǎn)換朋贬。

  • 設(shè)置 View 的 android:transitionName,這個是用來給需要共享的元素作一個標(biāo)記窜骄。既然是標(biāo)記锦募,就需要兩個 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" />

詳情頁面的狀態(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" />

重要的是兩個 View 都包含一個共同的 android:transitionName "rl_offer_item"邻遏,后面跳轉(zhuǎn)會用到該參數(shù)糠亩。

  • 進(jìn)行共享元素跳轉(zhuǎn):先判斷當(dāng)前系統(tǒng)版本,大于 Android 5.0 版本進(jìn)行動畫跳轉(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 方法三個參數(shù)党远,很好理解:第一個 activity削解,注意這里是 Activity 并不是 Context。第二個是要跳轉(zhuǎn)的 View 實(shí)例沟娱、最好一個就是在 xml 中定義的 transitionName "rl_offer_item"氛驮。
經(jīng)過上述步驟就可以實(shí)現(xiàn)一個簡單的共享元素動畫。

其它使用方式
如果不喜歡在 xml 中進(jìn)行設(shè)置济似,可以使用 View.setTransitionName() 方法給 View 設(shè)置 transitionName矫废,不過要注意是 API 21 以上的:
另外還有更簡便的 ViewCompat.setTransitionName() 兼容方法來設(shè)置

if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
    iv_status.setTransitionName("rl_offer_item");
}
// 或者
ViewCompat.setTransitionName("rl_offer_item");

同樣,在跳轉(zhuǎn)前也可以通過 View.getTransitionName() 或者ViewCompat.getTransitionName() 獲取到當(dāng)前 View 的 transitionName砰蠢。

更多功能

多個共享元素跳轉(zhuǎn)

有時候我們可能需要共享多個元素(View)蓖扑,讓兩個頁面多個相同的 View 作出類似“遷移”的效果,可以這樣做:

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ù)與之前不同台舱,第二個和第三個是 Pair 生成的對象律杠,可以看下 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;
    }

也就是說潭流,如果有多個元素進(jìn)行共享,使用 Pair 把 View 和它的 transtionName 綁定柜去,最后逗號拼接傳遞即可灰嫉。

  • Pair 一個很簡單的類,相當(dāng)于把兩個對象綁定起來合并為一個方便傳遞嗓奢。

自定義共享元素動畫(Transtion)模式

如果默認(rèn)的共享元素動畫不滿足需要讼撒,還可以自定義,只需在 values-v21下 app 的主 style 指定自定義 Transtion即可:

<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
  ...
  <!-- 定義共享元素動畫 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 中自己選擇根盒,也可以組合一個 transtionSet。

系統(tǒng)提供的transitionSet

2.2 詳情折疊 View

先來看一下詳情頁面的整體效果:

詳情

布局文件 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="北京 朝陽 -- 上海 青浦陽 -- 上海 青浦陽 -- 上海 青浦" />

            <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)價:2000元" />
              
        <!--省略一些布局-->

        </LinearLayout>

    </android.support.v4.widget.NestedScrollView>

</android.support.design.widget.CoordinatorLayout>

這就是之前提到過的 CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 組合物蝙,看上去比較唬人炎滞,我們慢慢看:

1. CoordinatorLayout

官方文檔對它的描述:

  • 作為某個頁面的根布局(xml 中類似 LinearLayout 等的頂級布局);
  • 作為一個容器:其中一個或多個 View 有特殊相互作用茬末。

使用:

  • 通過定義子 View 的 Behaviors 來確定子 View 直接的聯(lián)系厂榛,比如可以設(shè)置 A View 滑動的時候,B View 也跟著滑動丽惭。

比如上面的例子击奶,當(dāng) NestedScrollView 向上滑動時,會通過回調(diào)方法告知父 View 也就是 CoordinatorLayout 滑動的距離责掏。
CoordinatorLayout再遍歷所有子 View柜砾,拿到子 View 設(shè)置的 Behavior,通過 Behavior 可以告知 AppBarLayout 滑動偏移的距離换衬,完成滑動痰驱。

具體原理可參考:Material Design系列教程(5) - NestedScrollView

2. AppBarLayout

官網(wǎng)描述:

  • 一個垂直的 LinearLayout,MaterialDesign 設(shè)計(jì)導(dǎo)航欄的實(shí)現(xiàn)

使用:

  • 子 View 需要設(shè)置 app:layout_scrollFlagssetScrollFlags(int) 來確定想實(shí)現(xiàn)的滑動效果瞳浦;
  • 該 View 嚴(yán)重依賴于 CoordinatorLayout担映,也就是說要使用 CoordinatorLayout 作為其父布局,不然無法實(shí)現(xiàn)大部分功能和效果叫潦;
  • 通過給另外一個 View 設(shè)置 AppBarLayout.ScrollingViewBehavior 來確定 AppBarLayout 何時滑動蝇完。

根據(jù)特性描述,結(jié)合上文的詳情頁面布局矗蕊,寫一個省略版的:

<!--外層需要 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è)置滑動 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 來確定-->
             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 跟隨滾動(就像 RecyclerView 添加 Header)傻咖。
    enterAlways 表示總是最先出現(xiàn)朋魔,當(dāng) Toolbar 向上滑出屏幕,手指下滑時卿操,Toolbar 優(yōu)先滑動出來警检。等到 Toolbar 展示完畢孙援,再由其它 View 接收滑動事件(例子中的 NestedScrollView 接著滑動)。
    這里再記錄下其它三個 Flags:
    enterAlwaysCollapsed 表示最先出現(xiàn)扇雕,直至最小高度赃磨。等到最小高度展示完畢,NestedScrollView 進(jìn)行滑動洼裤,完畢后再接著滑動 Toolbar 到最大高度。
    exitUntilCollapsed View 向上滾動時溪王,跟隨縮短至最小高度腮鞍。然后不再變化,保留在屏幕頂端莹菱。上文詳情頁例子用到了這個效果
    snap 像一個吸附效果移国。滑動完畢松開手指道伟,要么滑動出屏幕迹缀,要么保留在頁面中。

參考 Android 詳細(xì)分析AppBarLayout的五種ScrollFlags

  • NestedScrollView 設(shè)置 app:layout_behavior蜜徽,上文提到過 CoordinatorLayout 會遍歷所有子 View 獲取其 Behavior祝懂,就是這里設(shè)置的 app:layout_behavior。
    這里使用的 Behavior 是 appbar_scrolling_view_behavior拘鞋,這對應(yīng)著 AppBarLayout 的一個靜態(tài)內(nèi)部類 ScrollingViewBehavior砚蓬。到這里一些部件就湊齊了:
    NestedScrollView 滑動,回調(diào)方法給CoordinatorLayout盆色,CoordinatorLayout再通過 Behavior 把要滑動的距離等參數(shù)傳遞灰蛙,最后 AppBarLayout 的 ScrollingViewBehavior起到一個更新 AppBarLayout 的作用。

3. CollapsingToolbarLayout

官網(wǎng)描述:

CollapsingToolbarLayout 用來實(shí)現(xiàn)一個可折疊的應(yīng)用程序工具欄隔躲,它被設(shè)計(jì)作為 AppBarLayout 的直接子View摩梧。

特點(diǎn):

  • Collapsing title:可跟隨滑動發(fā)生大小以及位置變化的標(biāo)題,可以通過 xml app:title=""設(shè)置宣旱,也可以通過代碼 setTitle(CharSequence) 設(shè)置仅父。優(yōu)先級高于 Toolbar 設(shè)置的標(biāo)題;
  • Content scrim:內(nèi)容遮罩xml app:contentScrim=""/setContentScrim(Drawable)
    設(shè)置响鹃,相當(dāng)于給 CollapsingToolbarLayout 設(shè)置一個增強(qiáng)版的 background贺纲,該 background 會跟隨滑動發(fā)生例如透明度等的變化;
  • Status bar scrim:狀態(tài)欄遮罩xml app:statusBarScrim=""/setStatusBarScrim(Drawable)
    設(shè)置旺上,CollapsingToolbarLayout 折疊時狀態(tài)欄顏色背景等叹誉,需要在 LOLLIPOP 且設(shè)置 android:fitsSystemWindows="true"
  • Parallax scrolling children:視差系數(shù) xmlapp:layout_collapseParallaxMultiplier=""忿项,取值在 0-1.0 之間蓉冈。
  • Pinned position children:子 View 可以選擇全局固定在空間中城舞,比如給 Toolbar 設(shè)置 xmlapp:layout_collapseMode="pin"表示固定在頂部不跟隨移動、app:layout_collapseMode="parallax"表示跟隨 CollapsingToolbarLayout 進(jìn)行視差移動寞酿。

簡單記錄一下實(shí)現(xiàn)原理家夺,AppbarLayout 維護(hù)了一個List List<AppBarLayout.BaseOnOffsetChangedListener> listeners 保存了所有監(jiān)聽。在 AppbarLayout 進(jìn)行偏移伐弹,比如高度變化時拉馋,遍歷通知這些 listener。

當(dāng)然 CollapsingToolbarLayout 內(nèi)部有一個 OffsetUpdateListener 就是實(shí)現(xiàn)于 BaseOnOffsetChangedListener 的惨好,在 CollapsingToolbarLayout 初始化時會調(diào)用 AppbarLayout 的方法把自己的 listener 添加到 AppbarLayout 維護(hù)的監(jiān)聽列表里煌茴。 所以在AppbarLayout發(fā)生變化時,CollapsingToolbarLayout會收到通知日川。

CollapsingToolbarLayout 內(nèi)的 Listener 收到通知時蔓腐,再改變自己 View 的狀態(tài),比如子 View 的展示與隱藏龄句,透明度的變化等回论。這樣上面例子中的變化效果就可以理解了。

  1. NestedScrollView

像一個 ScrollView分歇,但是支持嵌套滾動傀蓉。

官方文檔也沒有太多的介紹,接下來看源碼吧:

NestedScrollView 實(shí)現(xiàn)了兩個接口:NestedScrollingParent2 NestedScrollingChild2卿樱,分別用于作為父布局和子布局處理滑動事件僚害。CoordinatorLayout 只實(shí)現(xiàn)了 NestedScrollingParent2 接口,說明它只支持作為父布局處理嵌套滑動繁调。

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 中我們提到過萨蚕,NestedScrollView通過回調(diào)方法告知父 View,就是通過遍歷 NestedScrollView 的父 View蹄胰,如果它們 instanceof NestedScrollingParent2岳遥,就調(diào)用相關(guān)接口方法傳遞信息。我們主要關(guān)注滑動事件裕寨,接下來看 NestedScrollView 收到點(diǎn)擊事件之后的源碼:

NestedScrollView # onTouch()

public boolean onTouchEvent(MotionEvent ev) {
    ...
    switch (actionMasked) {
        case MotionEvent.ACTION_DOWN: {
            ...
            break;
        }
        case MotionEvent.ACTION_MOVE:
            ...
            // deltaY:垂直移動的距離浩蓉,deltaY = 上一次y值 - 當(dāng)前y值
            int deltaY = mLastMotionY - y;
            // 子 view 準(zhǔn)備滑動,通知父控件
            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];
            }
            ...
            // 在拖動狀態(tài)下
            if (mIsBeingDragged) {
                ...
                // 子 view 消費(fèi)滑動事件后捻艳,將消費(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;
}

拿到手指滑動的距離 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 類的實(shí)例,這個類主要幫助處理當(dāng)前 View 作為嵌套滑動子 View 時的處理庆猫,這里看下 mChildHelper 的 dispatchNestedPreScroll() 方法做了啥

NestedScrollingChildHelper # dispatchNestedPreScroll()

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
                                       @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type) {
    // 如果開啟嵌套滑動认轨,默認(rèn)開啟
    if (isNestedScrollingEnabled()) {
        final ViewParent parent = getNestedScrollingParentForType(type);
        if (parent == null) {
            return false;
        }
        // 如果存在滑動距離
        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 用來記錄消耗的滑動距離,第一個元素 x 軸(水平滑動距離)月培,第二個 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;
}

這個方法主要做了三件事:

  • 拿到 ViewParent嘁字,也就是父 View恩急;
  • 判斷如果存在滑動距離,調(diào)用 ViewParentCompat.onNestedPreScroll() 將距離等參數(shù)傳遞給父 View 處理纪蜒;
  • 返回結(jié)果:父 View 是否消耗了滑動數(shù)據(jù)衷恭。

這里主要看這個 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() 方法,將滑動參數(shù)交個父 View 處理猬错。

由于例子中 NestedScrollView 的父 View 是 CoordinatorLayout牙丽,我們就來看下 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);
    }

}
  • 定義一個標(biāo)記表示是否接收或者說消費(fèi)滑動事件 accepted;
  • 遍歷子 View兔魂,拿到其 Behavior,調(diào)用該 Behavior 的 onNestedPreScroll() 方法處理滑動事件举娩。既然是遍歷析校,就來挨個看一下例子中我們設(shè)置的 Behavior:
    • AppBarLayout 是注解方式指定的 @DefaultBehavior(AppBarLayout.Behavior.class)
    • NestScrollView 是 xml 中指定的 appbar_scrolling_view_behavior铜涉,對應(yīng)的是 AppbarLayout 的一個靜態(tài)內(nèi)部類 ScrollingViewBehavior智玻。

首先來看 NestScrollView 指定的 ScrollingViewBehavior 中的 onNestedPreScroll() 方法,最后發(fā)現(xiàn)只調(diào)用了頂級父類 CoordinatorLayout.Behavior 的空方法 onNestedPreScroll()芙代,所以這里不必理會吊奢。

那么接著來看另一個子 View AppBarLayoutonNestedPreScroll() 方法,所以上文說 NestScrollView的滑動會影響 AppBarLayout的高度纹烹,就是因?yàn)檫@里調(diào)用了 AppBarLayout 設(shè)置的 Behavior 來改變 AppBarLayout 的高度页滚。
AppBarLayout 設(shè)置的 AppBarLayout.Behavior.class 并沒有定義 onNestedPreScroll(),所以看這個 Behavior 的父類:

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);
        }
    }
}

跳過一些細(xì)節(jié)铺呵,了解到調(diào)用了 scroll() 方法執(zhí)行后面的邏輯裹驰。注意下這里的 consumed[1],它是經(jīng)過層層傳遞而來的片挂,用來記錄消耗的滑動距離的數(shù)組幻林,consumed[1] 表示垂直滑動距離...

可以猜想到 scroll() 方法就是進(jìn)行滑動的重要方法,該方法又是由 BaseBehavior 的父類 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)過了一系列操作沪饺,我們最后終于得到了 consumed,也就是父 View 消耗的距離闷愤。最后會把消耗的距離返回給 NestedScrollView整葡,NestedScrollView 拿到父 View 消費(fèi)的距離,可以計(jì)算出剩下可滑動距離用于自己滑動事件的處理肝谭。

這個方法最后返回了父 View 消費(fèi)的距離掘宪,嚴(yán)格來說蛾扇,是父 View 把數(shù)據(jù)交給 Behavior 消費(fèi)了。具體是怎么處理的呢魏滚,再看一下 setHeaderTopBottomOffset 的具體實(shí)現(xiàn):

首先拿到當(dāng)前 View 距離頂部的偏移量镀首,如果 minOffset 不等于 0 且大于等于 minOffset 且小于等于 maxOffset ,則進(jìn)行滑動事件消費(fèi)鼠次,這里可以理解為該 View 的高度在最大高度和最小高度之間才進(jìn)行滑動更哄。接下來就是進(jìn)行滑動了:

關(guān)鍵代碼就在上面 this.setTopAndBottomOffset(newOffset)。這個方法是又是由 HeaderBehavior 的父類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 來更改 View 的頂部和底部的偏移量腥寇,this.viewOffsetHelper.setTopAndBottomOffset(offset) 這個方法最后會調(diào)用 View 的 invalidate() 方法成翩。有了數(shù)據(jù)、有了重繪赦役,最終改變 View 的屬性麻敌,這個過程不再贅述了。

到這里掂摔,NestedScrollView 收到手指滑動事件的一部分操作才算完成术羔,說了這么多在 NestedScrollView 的代碼中進(jìn)行了一行(#笑哭),回過頭來看看:

public boolean onTouchEvent(MotionEvent ev) {
    ...
    switch (actionMasked) {
        case MotionEvent.ACTION_DOWN: {
            ...
            break;
        }
        case MotionEvent.ACTION_MOVE:
            ...
            // deltaY:垂直移動的距離乙漓,deltaY = 上一次y值 - 當(dāng)前y值
            int deltaY = mLastMotionY - y;
            // 子 view 準(zhǔn)備滑動级历,通知父控件
            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];
            }
            ...
            // 在拖動狀態(tài)下
            if (mIsBeingDragged) {
                ...
                // 自己滑動剩下的距離
                if (this.overScrollByCompat(0, deltaY, 0, this.getScrollY(), 0, range, 0, 0, true) && !this.hasNestedScrollingParent(0)) {
                        this.mVelocityTracker.clear();
                    }
                // 子 view 消費(fèi)滑動事件后叭披,將消費(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;
}

就是這個 dispatchNestedPreScroll() 方法執(zhí)行了一大串邏輯寥殖,我們再簡單總結(jié)下:

  1. dispatchNestedPreScroll 方法傳遞滑動距離,找到實(shí)現(xiàn)了 NestedScrollingParent2 接口的父 View涩蜘,也就是 CoordinatorLayout嚼贡;
  2. 調(diào)用 CoordinatorLayoutonNestedPreScroll 方法,讓父 View 消費(fèi)滑動事件同诫;
  3. 父 View CoordinatorLayout遍歷獲取子 View 設(shè)置的 Behavior编曼,然后調(diào)用這個 Behavior 的 onNestedPreScroll() 方法去滑動子 View;
  4. 子 View 滑動完成之后剩辟,返回未滑動剩余的距離掐场,再由父 View CoordinatorLayout 返回給 NestedScrollView
  5. NestedScrollView 拿到未消費(fèi)的距離贩猎,自己經(jīng)過滑動之后熊户,再把剩下的距離交給 父 View CoordinatorLayout 處理。就是上面的 dispatchNestedScroll() 方法吭服。本文就不在分析了...

到這里就對整個流程有了一個大概的了解嚷堡,看懂了這一塊的流程,其它的應(yīng)該會比較好理解了。

三蝌戒、總結(jié)

  • Material Design 已經(jīng)推出好多年了串塑,雖然國內(nèi) app 使用該設(shè)計(jì)思想的少之又少,但就我個人來說還是比較喜歡的北苟,所以會盡量在自己的項(xiàng)目應(yīng)用該設(shè)計(jì)思想桩匪。

  • 共享元素動畫: 使用需要靈活。和 CardView 一樣友鼻,效果雖好傻昙,不可在一個項(xiàng)目中過多使用。

  • CoordinatorLayout: 協(xié)調(diào)者布局彩扔,子 View 滑動時通知 CoordinatorLayout妆档、CoordinatorLayout 再通過其它子 View 設(shè)置的 Behaviors 促成滑動或其它效果。

  • AppBarLayout: app bar 的 MD 實(shí)現(xiàn)虫碉,配合父 View CoordinatorLayout 以及其它同級 View 的 Behaviors 可以實(shí)現(xiàn)滑動聯(lián)動效果贾惦。
    由于 AppBarLayout 是一個垂直的 LinearLayout,我們也可以在其內(nèi)按照順序放置其它 View敦捧。比如在上面例子中的 CollapsingToolbarLayout 底部添加 TabLayout纤虽,NestedScrollView 替換成 ViewPager 同時設(shè)置想要的 app:layout_behavior ,就可以實(shí)現(xiàn)一個 TabLayout+ViewPager 的組合绞惦。

  • CollapsingToolbarLayout: 根據(jù)推薦父 View AppBarLayout 的滑動,可以實(shí)現(xiàn)各種比如透明度洋措、縮放的效果济蝉。

  • NestedScrollView 實(shí)現(xiàn)了嵌套滑動的 ScrollView。通過接口方法可以告知父 View 或子 View 自己滑動的距離菠发,實(shí)現(xiàn)嵌套滑動王滤。
    AppBarLayout 協(xié)作的不僅限于 NestedScrollView,也可以是 RecyclerView 或其它滓鸠,只要指定好與 AppBarLayout 協(xié)作的 Behaviors 就可以雁乡。

以上就是本文全部內(nèi)容,如果錯誤或分析不恰當(dāng)之處望指出糜俗,感謝踱稍!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市悠抹,隨后出現(xiàn)的幾起案子珠月,更是在濱河造成了極大的恐慌,老刑警劉巖楔敌,帶你破解...
    沈念sama閱讀 221,635評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件啤挎,死亡現(xiàn)場離奇詭異,居然都是意外死亡卵凑,警方通過查閱死者的電腦和手機(jī)庆聘,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評論 3 399
  • 文/潘曉璐 我一進(jìn)店門胜臊,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人伙判,你說我怎么就攤上這事象对。” “怎么了澳腹?”我有些...
    開封第一講書人閱讀 168,083評論 0 360
  • 文/不壞的土叔 我叫張陵织盼,是天一觀的道長。 經(jīng)常有香客問我酱塔,道長沥邻,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,640評論 1 296
  • 正文 為了忘掉前任羊娃,我火速辦了婚禮唐全,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘蕊玷。我一直安慰自己邮利,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,640評論 6 397
  • 文/花漫 我一把揭開白布垃帅。 她就那樣靜靜地躺著延届,像睡著了一般。 火紅的嫁衣襯著肌膚如雪贸诚。 梳的紋絲不亂的頭發(fā)上方庭,一...
    開封第一講書人閱讀 52,262評論 1 308
  • 那天,我揣著相機(jī)與錄音酱固,去河邊找鬼械念。 笑死,一個胖子當(dāng)著我的面吹牛运悲,可吹牛的內(nèi)容都是我干的龄减。 我是一名探鬼主播,決...
    沈念sama閱讀 40,833評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼班眯,長吁一口氣:“原來是場噩夢啊……” “哼希停!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起署隘,我...
    開封第一講書人閱讀 39,736評論 0 276
  • 序言:老撾萬榮一對情侶失蹤脖苏,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后定踱,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體棍潘,經(jīng)...
    沈念sama閱讀 46,280評論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,369評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了亦歉。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片恤浪。...
    茶點(diǎn)故事閱讀 40,503評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖肴楷,靈堂內(nèi)的尸體忽然破棺而出水由,到底是詐尸還是另有隱情,我是刑警寧澤赛蔫,帶...
    沈念sama閱讀 36,185評論 5 350
  • 正文 年R本政府宣布砂客,位于F島的核電站,受9級特大地震影響呵恢,放射性物質(zhì)發(fā)生泄漏鞠值。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,870評論 3 333
  • 文/蒙蒙 一渗钉、第九天 我趴在偏房一處隱蔽的房頂上張望彤恶。 院中可真熱鬧,春花似錦鳄橘、人聲如沸声离。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,340評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽术徊。三九已至,卻和暖如春鲸湃,著一層夾襖步出監(jiān)牢的瞬間赠涮,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,460評論 1 272
  • 我被黑心中介騙來泰國打工唤锉, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人别瞭。 一個月前我還...
    沈念sama閱讀 48,909評論 3 376
  • 正文 我出身青樓窿祥,卻偏偏與公主長得像,于是被迫代替她去往敵國和親蝙寨。 傳聞我的和親對象是個殘疾皇子晒衩,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,512評論 2 359