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

一、回顧

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

1.1 色彩

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


詢(xún)價(jià)

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

詢(xún)價(jià)改

可以看到列表好看了許多,重要的是各種訂單狀態(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à)

  1. 使用共享元素動(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。

  2. 接著需要指定 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。

MaterialComponents

  1. 做好上述準(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。

系統(tǒng)提供的transitionSet

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 組合,看上去比較唬人,我們慢慢看:

1. CoordinatorLayout

官方文檔對(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)。

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

2. AppBarLayout

官網(wǎng)描述:

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

使用:

  • 子 View 需要設(shè)置 app:layout_scrollFlagssetScrollFlags(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è)面中。

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

  • 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 的作用。

3. CollapsingToolbarLayout

官網(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ù) xmlapp:layout_collapseParallaxMultiplier="",取值在 0-1.0 之間。
  • Pinned position children:子 View 可以選擇全局固定在空間中,比如給 Toolbar 設(shè)置 xmlapp: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 的展示與隱藏,透明度的變化等。這樣上面例子中的變化效果就可以理解了。

  1. NestedScrollView

像一個(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 AppBarLayoutonNestedPreScroll() 方法,所以上文說(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é)下:

  1. dispatchNestedPreScroll 方法傳遞滑動(dòng)距離,找到實(shí)現(xiàn)了 NestedScrollingParent2 接口的父 View,也就是 CoordinatorLayout;
  2. 調(diào)用 CoordinatorLayoutonNestedPreScroll 方法,讓父 View 消費(fèi)滑動(dòng)事件;
  3. 父 View CoordinatorLayout遍歷獲取子 View 設(shè)置的 Behavior,然后調(diào)用這個(gè) Behavior 的 onNestedPreScroll() 方法去滑動(dòng)子 View;
  4. 子 View 滑動(dòng)完成之后,返回未滑動(dòng)剩余的距離,再由父 View CoordinatorLayout 返回給 NestedScrollView。
  5. NestedScrollView 拿到未消費(fèi)的距離,自己經(jīng)過(guò)滑動(dòng)之后,再把剩下的距離交給 父 View CoordinatorLayout 處理。就是上面的 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)之處望指出,感謝!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容