使用MD风格,让你的项目更好看


/   今日科技快讯   /

近日,埃隆·马斯克的净资产超过Facebook联合创始人马克·扎克伯格,成为仅次于亚马逊首席执行官杰夫·贝索斯和微软联合创始人比尔·盖茨的世界第三大富豪,此前特斯拉股价在经历了股票拆分后出现反弹。

/   作者简介   /

本篇文章来自Marker_Sky的投稿,和大家分享了如何实现一个MD风格详情页面,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!

Marker_Sky的博客地址:

https://www.jianshu.com/u/d46b4a47db84

/   回顾   /

色彩

首先来回顾下之前的问题,项目原来的 UI:

经过一番改造之后变成了这样:

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

  • 绿色:已中标订单

  • 黄色:待中标订单

  • 红色:已取消订单

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

图标

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

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

效果预览

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

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

  • 共享元素动画

  • CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar

接下来就看具体实现吧。

/   正文   /

共享元素动画

使用共享元素动画,首先需要引入Material Design包:

implementation 'com.android.support:design:xxx'

xxx后缀版本号最好与项目targetSdkVersion版本相同,避免出现适配问题,比如demo中的targetSdkVersion 28,使用的design版本为 28.0.0。

接着需要指定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。

做好上述准备工作,就可以开始设置动画了。首先要确定共享的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。

详情折叠 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组合,看上去比较唬人,我们慢慢看。

CoordinatorLayout

官方文档对它的描述:

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

使用:

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

比如上面的例子,当 NestedScrollView 向上滑动时,会通过回调方法告知父 View 也就是 CoordinatorLayout 滑动的距离。

CoordinatorLayout再遍历所有子 View,拿到子 View 设置的 Behavior,通过 Behavior 可以告知 AppBarLayout 滑动偏移的距离,完成滑动。

AppBarLayout

官网描述:

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

使用:

  • 子 View 需要设置 app:layout_scrollFlags或 setScrollFlags(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:

  1. enterAlwaysCollapsed 表示最先出现,直至最小高度。等到最小高度展示完毕,NestedScrollView 进行滑动,完毕后再接着滑动 Toolbar 到最大高度。

  2. exitUntilCollapsed View 向上滚动时,跟随缩短至最小高度。然后不再变化,保留在屏幕顶端。上文详情页例子用到了这个效果

  3. snap 像一个吸附效果。滑动完毕松开手指,要么滑动出屏幕,要么保留在页面中。

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

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 的展示与隐藏,透明度的变化等。这样上面例子中的变化效果就可以理解了。

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

在上文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 AppBarLayout的onNestedPreScroll()方法,所以上文说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消耗的距离。最后会把消耗的距离返回给NestedScrollView,NestedScrollView拿到父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()方法执行了一大串逻辑,我们再简单总结下:

  • dispatchNestedPreScroll方法传递滑动距离,找到实现了NestedScrollingParent2接口的父View,也就是CoordinatorLayout;

  • 调用CoordinatorLayout的onNestedPreScroll方法,让父 View 消费滑动事件;

  • 父View CoordinatorLayout遍历获取子View设置的Behavior,然后调用这个Behavior的 onNestedPreScroll()方法去滑动子View;

  • 子View滑动完成之后,返回未滑动剩余的距离,再由View CoordinatorLayout返回给NestedScrollView。

  • 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就可以。

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

推荐阅读:

分享一个可以装逼的开发技巧

PermissionX重磅更新,支持自定义权限提醒对话框

Jetpack新成员,App Startup一篇就懂

欢迎关注我的公众号

学习技术或投稿

长按上图,识别图中二维码即可关注

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值