Android 项目优化笔记(五):实现一个 MD 风格详情页

一、回顾

前文索引:
Android 项目优化笔记(一):概览

1.1 色彩

首先来回顾下之前的问题,项目原来的 UI:
询价
经过一番改造之后变成了这样:

询价改

可以看到列表好看了许多,重要的是各种订单状态有了不同 颜色 作为指示,不同的色彩能带给用户最直观的感受。

  • 绿色:已中标订单
  • 黄色:待中标订单
  • 红色:已取消订单

列表点击跳转到详情,这些颜色就可以很好的利用起来。

1.2 图标

可以看到每条数据右上角都有一个代表当前订单状态的小 Chips,而跳转到详情页时,必定也会有类似的文字或图标表示当前订单的状态。

这就让我想到了共享元素动画,或许可以用动画把列表和详情两个页面连接起来。

效果预览

经过一番思考和操作之后,完成了如下效果:
整体效果

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

  • 共享元素动画
  • CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar

接下来就看具体实现吧。

二、开始
2.1 共享元素动画

  1. 使用共享元素动画,首先需要引入 Material Design 包
    implementation 'com.android.support:design:xxx'
    *xxx后缀版本号最好与项目 targetSdkVersion 版本相同,避免出现适配问题,比如 demo 中的 targetSdkVersion 28,使用的 design 版本为 28.0.0

  2. 接着需要指定 Material Theme 相关主题,因为 Material 主题只支持 Android 5.0 以上版本,所以需要定义在 values-v21 文件夹下 style.xml
    同时需要指定 android:windowContentTransitions 允许使用 window 内容转换动画:

<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 可供选择,我们可以根据实际情况选择合适的 style。
MaterialComponents

  1. 做好上述准备工作,就可以开始设置动画了。首先要确定共享的 View,比如例子中的订单状态 TextView,跳转到详情共享了状态图标 ImageView。

一般来说,共享相同类型以及相同内容的 View 会达到比较好的效果。但是不同类型的 View 也是可以共享的,本文中 TextView 与 ImageView 共享虽说不太规范,却能更好的帮助理解共享元素是针对 View 的动画转换。

  • 设置 View 的 android:transitionName,这个是用来给需要共享的元素作一个标记。既然是标记,就需要两个 View 作相同的标记。

Item 布局中的状态 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="待中标"
    android:textColor="@color/white" />

详情页面的状态 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”,后面跳转会用到该参数。

  • 进行共享元素跳转:先判断当前系统版本,大于 Android 5.0 版本进行动画跳转
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 方法三个参数,很好理解:第一个 activity,注意这里是 Activity 并不是 Context。第二个是要跳转的 View 实例、最好一个就是在 xml 中定义的 transitionName “rl_offer_item”
经过上述步骤就可以实现一个简单的共享元素动画。

其它使用方式
如果不喜欢在 xml 中进行设置,可以使用 View.setTransitionName() 方法给 View 设置 transitionName,不过要注意是 API 21 以上的:
另外还有更简便的 ViewCompat.setTransitionName() 兼容方法来设置

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

同样,在跳转前也可以通过 View.getTransitionName() 或者ViewCompat.getTransitionName() 获取到当前 View 的 transitionName。

更多功能

多个共享元素跳转

有时候我们可能需要共享多个元素(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()方法传递的参数与之前不同,第二个和第三个是 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;
    }

也就是说,如果有多个元素进行共享,使用 Pair 把 View 和它的 transtionName 绑定,最后逗号拼接传递即可。

  • Pair 一个很简单的类,相当于把两个对象绑定起来合并为一个方便传递。

自定义共享元素动画(Transtion)模式

如果默认的共享元素动画不满足需要,还可以自定义,只需在 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只是其中一种,可以在系统提供的多种 transitionSet 中自己选择,也可以组合一个 transtionSet。
系统提供的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="报价:2000元" />
              
        <!--省略一些布局-->

        </LinearLayout>

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

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

这就是之前提到过的 CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 组合,看上去比较唬人,我们慢慢看:

1. CoordinatorLayout

官方文档对它的描述:

  • 作为某个页面的根布局(xml 中类似 LinearLayout 等的顶级布局);
  • 作为一个容器:其中一个或多个 View 有特殊相互作用。

使用:

  • 通过定义子 View 的 Behaviors 来确定子 View 直接的联系,比如可以设置 A View 滑动的时候,B View 也跟着滑动。

比如上面的例子,当 NestedScrollView 向上滑动时,会通过回调方法告知父 View 也就是 CoordinatorLayout 滑动的距离。
CoordinatorLayout再遍历所有子 View,拿到子 View 设置的 Behavior,通过 Behavior 可以告知 AppBarLayout 滑动偏移的距离,完成滑动。

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

2. AppBarLayout

官网描述:

  • 一个垂直的 LinearLayout,MaterialDesign 设计导航栏的实现

使用:

  • 子 View 需要设置 app:layout_scrollFlagssetScrollFlags(int) 来确定想实现的滑动效果;
  • 该 View 严重依赖于 CoordinatorLayout,也就是说要使用 CoordinatorLayout 作为其父布局,不然无法实现大部分功能和效果;
  • 通过给另外一个 View 设置 AppBarLayout.ScrollingViewBehavior 来确定 AppBarLayout 何时滑动。

根据特性描述,结合上文的详情页面布局,写一个省略版的:

<!--外层需要 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 设置滑动 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,设置 
 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 以发挥 AppBarLayout大部分效果;
  • AppBarLayout 的子布局(例子是 Toolbar)设置 app:layout_scrollFlags,注意 Toolbar 的 app:layout_scrollFlags
    scroll 表示子 View 跟随滚动(就像 RecyclerView 添加 Header)。
    enterAlways 表示总是最先出现,当 Toolbar 向上滑出屏幕,手指下滑时,Toolbar 优先滑动出来。等到 Toolbar 展示完毕,再由其它 View 接收滑动事件(例子中的 NestedScrollView 接着滑动)。
    这里再记录下其它三个 Flags:
    enterAlwaysCollapsed 表示最先出现,直至最小高度。等到最小高度展示完毕,NestedScrollView 进行滑动,完毕后再接着滑动 Toolbar 到最大高度。
    exitUntilCollapsed View 向上滚动时,跟随缩短至最小高度。然后不再变化,保留在屏幕顶端。上文详情页例子用到了这个效果
    snap 像一个吸附效果。滑动完毕松开手指,要么滑动出屏幕,要么保留在页面中。

参考 Android 详细分析AppBarLayout的五种ScrollFlags

  • NestedScrollView 设置 app:layout_behavior,上文提到过 CoordinatorLayout 会遍历所有子 View 获取其 Behavior,就是这里设置的 app:layout_behavior。
    这里使用的 Behavior 是 appbar_scrolling_view_behavior,这对应着 AppBarLayout 的一个静态内部类 ScrollingViewBehavior。到这里一些部件就凑齐了:
    NestedScrollView 滑动,回调方法给CoordinatorLayoutCoordinatorLayout再通过 Behavior 把要滑动的距离等参数传递,最后 AppBarLayout 的 ScrollingViewBehavior起到一个更新 AppBarLayout 的作用。

3. CollapsingToolbarLayout

官网描述:

CollapsingToolbarLayout 用来实现一个可折叠的应用程序工具栏,它被设计作为 AppBarLayout 的直接子View。

特点:

  • Collapsing title:可跟随滑动发生大小以及位置变化的标题,可以通过 xml app:title=""设置,也可以通过代码 setTitle(CharSequence) 设置。优先级高于 Toolbar 设置的标题;
  • Content scrim:内容遮罩xml app:contentScrim=""/setContentScrim(Drawable)
    设置,相当于给 CollapsingToolbarLayout 设置一个增强版的 background,该 background 会跟随滑动发生例如透明度等的变化;
  • Status bar scrim:状态栏遮罩xml app:statusBarScrim=""/setStatusBarScrim(Drawable)
    设置,CollapsingToolbarLayout 折叠时状态栏颜色背景等,需要在 LOLLIPOP 且设置 android:fitsSystemWindows="true"
  • Parallax scrolling children:视差系数 xmlapp:layout_collapseParallaxMultiplier="",取值在 0-1.0 之间。
  • Pinned position children:子 View 可以选择全局固定在空间中,比如给 Toolbar 设置 xmlapp:layout_collapseMode="pin"表示固定在顶部不跟随移动、app:layout_collapseMode="parallax"表示跟随 CollapsingToolbarLayout 进行视差移动。

简单记录一下实现原理,AppbarLayout 维护了一个List List<AppBarLayout.BaseOnOffsetChangedListener> listeners 保存了所有监听。在 AppbarLayout 进行偏移,比如高度变化时,遍历通知这些 listener。

当然 CollapsingToolbarLayout 内部有一个 OffsetUpdateListener 就是实现于 BaseOnOffsetChangedListener 的,在 CollapsingToolbarLayout 初始化时会调用 AppbarLayout 的方法把自己的 listener 添加到 AppbarLayout 维护的监听列表里。 所以在AppbarLayout发生变化时,CollapsingToolbarLayout会收到通知。

CollapsingToolbarLayout 内的 Listener 收到通知时,再改变自己 View 的状态,比如子 View 的展示与隐藏,透明度的变化等。这样上面例子中的变化效果就可以理解了。

  1. NestedScrollView

像一个 ScrollView,但是支持嵌套滚动。

官方文档也没有太多的介绍,接下来看源码吧:

NestedScrollView 实现了两个接口:NestedScrollingParent2 NestedScrollingChild2,分别用于作为父布局和子布局处理滑动事件。CoordinatorLayout 只实现了 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通过回调方法告知父 View,就是通过遍历 NestedScrollView 的父 View,如果它们 instanceof NestedScrollingParent2,就调用相关接口方法传递信息。我们主要关注滑动事件,接下来看 NestedScrollView 收到点击事件之后的源码:

NestedScrollView # onTouch()

public boolean onTouchEvent(MotionEvent ev) {
    ...
    switch (actionMasked) {
        case MotionEvent.ACTION_DOWN: {
            ...
            break;
        }
        case MotionEvent.ACTION_MOVE:
            ...
            // deltaY:垂直移动的距离,deltaY = 上一次y值 - 当前y值
            int deltaY = mLastMotionY - y;
            // 子 view 准备滑动,通知父控件
            if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                    ViewCompat.TYPE_TOUCH)) {
                // 父控件消费了mScrollConsumed[1],子 view 还剩下 deltaY 距离可以消费
                deltaY -= mScrollConsumed[1];
                vtev.offsetLocation(0, mScrollOffset[1]);
                mNestedYOffset += mScrollOffset[1];
            }
            ...
            // 在拖动状态下
            if (mIsBeingDragged) {
                ...
                // 子 view 消费滑动事件后,将消费距离详情通知父控件
                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 之后调用内部方法通知父控件:

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 类的实例,这个类主要帮助处理当前 View 作为嵌套滑动子 View 时的处理,这里看下 mChildHelper 的 dispatchNestedPreScroll() 方法做了啥

NestedScrollingChildHelper # dispatchNestedPreScroll()

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
                                       @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type) {
    // 如果开启嵌套滑动,默认开启
    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];
            }
            // 数组 consumed 用来记录消耗的滑动距离,第一个元素 x 轴(水平滑动距离),第二个 y轴(垂直)
            if (consumed == null) {
                if (mTempNestedScrollConsumed == null) {
                    mTempNestedScrollConsumed = new int[2];
                }
                consumed = mTempNestedScrollConsumed;
            }
            consumed[0] = 0;
            consumed[1] = 0;
            // 传递数据
            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;
  • 判断如果存在滑动距离,调用 ViewParentCompat.onNestedPreScroll() 将距离等参数传递给父 View 处理;
  • 返回结果:父 View 是否消耗了滑动数据。

这里主要看这个 Helper 是怎么把数据传递给父 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 实现了 NestedScrollingParent2 接口,就调用它的 onNestedPreScroll() 方法,将滑动参数交个父 View 处理。

由于例子中 NestedScrollView 的父 View 是 CoordinatorLayout,我们就来看下 CoordinatorLayout 中的 onNestedPreScroll() 方法是怎么实现的:

CoordinatorLayout#onNestedPreScroll()

public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
    int xConsumed = 0;
    int yConsumed = 0;
    // 标记是否接受/消费这次事件
    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 设置的 Behavior
                CoordinatorLayout.Behavior viewBehavior = lp.getBehavior();
                if (viewBehavior != null) {
                    this.mTempIntPair[0] = this.mTempIntPair[1] = 0;
                    // 调用子 View 的 onNestedPreScroll 消费事件
                    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);
    }

}
  • 定义一个标记表示是否接收或者说消费滑动事件 accepted;
  • 遍历子 View,拿到其 Behavior,调用该 Behavior 的 onNestedPreScroll() 方法处理滑动事件。既然是遍历,就来挨个看一下例子中我们设置的 Behavior:
    • AppBarLayout 是注解方式指定的 @DefaultBehavior(AppBarLayout.Behavior.class)
    • NestScrollView 是 xml 中指定的 appbar_scrolling_view_behavior,对应的是 AppbarLayout 的一个静态内部类 ScrollingViewBehavior

首先来看 NestScrollView 指定的 ScrollingViewBehavior 中的 onNestedPreScroll() 方法,最后发现只调用了顶级父类 CoordinatorLayout.Behavior 的空方法 onNestedPreScroll(),所以这里不必理会。

那么接着来看另一个子 View AppBarLayoutonNestedPreScroll() 方法,所以上文说 NestScrollView的滑动会影响 AppBarLayout的高度,就是因为这里调用了 AppBarLayout 设置的 Behavior 来改变 AppBarLayout 的高度。
AppBarLayout 设置的 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);
        }
    }
}

跳过一些细节,了解到调用了 scroll() 方法执行后面的逻辑。注意下这里的 consumed[1],它是经过层层传递而来的,用来记录消耗的滑动距离的数组,consumed[1] 表示垂直滑动距离…

可以猜想到 scroll() 方法就是进行滑动的重要方法,该方法又是由 BaseBehavior 的父类 HeaderBehavior 实现的:

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

经过了一系列操作,我们最后终于得到了 consumed,也就是父 View 消耗的距离。最后会把消耗的距离返回给 NestedScrollViewNestedScrollView 拿到父 View 消费的距离,可以计算出剩下可滑动距离用于自己滑动事件的处理。

这个方法最后返回了父 View 消费的距离,严格来说,是父 View 把数据交给 Behavior 消费了。具体是怎么处理的呢,再看一下 setHeaderTopBottomOffset 的具体实现:

首先拿到当前 View 距离顶部的偏移量,如果 minOffset 不等于 0 且大于等于 minOffset 且小于等于 maxOffset ,则进行滑动事件消费,这里可以理解为该 View 的高度在最大高度和最小高度之间才进行滑动。接下来就是进行滑动了:

关键代码就在上面 this.setTopAndBottomOffset(newOffset)。这个方法是又是由 HeaderBehavior 的父类ViewOffsetBehavior实现的:

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) 这个方法最后会调用 View 的 invalidate() 方法。有了数据、有了重绘,最终改变 View 的属性,这个过程不再赘述了。

到这里,NestedScrollView 收到手指滑动事件的一部分操作才算完成,说了这么多在 NestedScrollView 的代码中进行了一行(#笑哭),回过头来看看:

public boolean onTouchEvent(MotionEvent ev) {
    ...
    switch (actionMasked) {
        case MotionEvent.ACTION_DOWN: {
            ...
            break;
        }
        case MotionEvent.ACTION_MOVE:
            ...
            // deltaY:垂直移动的距离,deltaY = 上一次y值 - 当前y值
            int deltaY = mLastMotionY - y;
            // 子 view 准备滑动,通知父控件
            if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                    ViewCompat.TYPE_TOUCH)) {
                // 父控件消费了mScrollConsumed[1],子 view 还剩下 deltaY 距离可以消费
                deltaY -= mScrollConsumed[1];
                vtev.offsetLocation(0, mScrollOffset[1]);
                mNestedYOffset += mScrollOffset[1];
            }
            ...
            // 在拖动状态下
            if (mIsBeingDragged) {
                ...
                // 自己滑动剩下的距离
                if (this.overScrollByCompat(0, deltaY, 0, this.getScrollY(), 0, range, 0, 0, true) && !this.hasNestedScrollingParent(0)) {
                        this.mVelocityTracker.clear();
                    }
                // 子 view 消费滑动事件后,将消费距离详情通知父控件
                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() 方法执行了一大串逻辑,我们再简单总结下:

  1. dispatchNestedPreScroll 方法传递滑动距离,找到实现了 NestedScrollingParent2 接口的父 View,也就是 CoordinatorLayout
  2. 调用 CoordinatorLayoutonNestedPreScroll 方法,让父 View 消费滑动事件;
  3. 父 View CoordinatorLayout遍历获取子 View 设置的 Behavior,然后调用这个 Behavior 的 onNestedPreScroll() 方法去滑动子 View;
  4. 子 View 滑动完成之后,返回未滑动剩余的距离,再由父 View CoordinatorLayout 返回给 NestedScrollView
  5. NestedScrollView 拿到未消费的距离,自己经过滑动之后,再把剩下的距离交给 父 View CoordinatorLayout 处理。就是上面的 dispatchNestedScroll() 方法。本文就不在分析了…

到这里就对整个流程有了一个大概的了解,看懂了这一块的流程,其它的应该会比较好理解了。

三、总结
  • Material Design 已经推出好多年了,虽然国内 app 使用该设计思想的少之又少,但就我个人来说还是比较喜欢的,所以会尽量在自己的项目应用该设计思想。

  • 共享元素动画: 使用需要灵活。和 CardView 一样,效果虽好,不可在一个项目中过多使用。

  • CoordinatorLayout: 协调者布局,子 View 滑动时通知 CoordinatorLayout、CoordinatorLayout 再通过其它子 View 设置的 Behaviors 促成滑动或其它效果。

  • AppBarLayout: app bar 的 MD 实现,配合父 View CoordinatorLayout 以及其它同级 View 的 Behaviors 可以实现滑动联动效果。
    由于 AppBarLayout 是一个垂直的 LinearLayout,我们也可以在其内按照顺序放置其它 View。比如在上面例子中的 CollapsingToolbarLayout 底部添加 TabLayout,NestedScrollView 替换成 ViewPager 同时设置想要的 app:layout_behavior ,就可以实现一个 TabLayout+ViewPager 的组合。

  • CollapsingToolbarLayout: 根据推荐父 View AppBarLayout 的滑动,可以实现各种比如透明度、缩放的效果。

  • NestedScrollView 实现了嵌套滑动的 ScrollView。通过接口方法可以告知父 View 或子 View 自己滑动的距离,实现嵌套滑动。
    AppBarLayout 协作的不仅限于 NestedScrollView,也可以是 RecyclerView 或其它,只要指定好与 AppBarLayout 协作的 Behaviors 就可以。

以上就是本文全部内容,如果错误或分析不恰当之处望指出,感谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值