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

tech2022-08-21  123

/   今日科技快讯   /

近日,埃隆·马斯克的净资产超过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:

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

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

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一篇就懂

欢迎关注我的公众号

学习技术或投稿

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

最新回复(0)