/ 今日科技快讯 /
近日,埃隆·马斯克的净资产超过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一篇就懂
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注