Material 系列文章:
Material Design 之 Toolbar 开发实践总结
Material Design之 AppbarLayout 开发实践总结
前面两篇文章讲了Toolbar 和 AppbarLayout 相关的东西,还没看过的同学可以去看看。前面我们说过,CoordinatorLayout很强大,它可以协调子View的交互动作,那么CoordinatorLayout它是怎么协调子View的呢?其实核心就是Behavior。那么今天讲的就是这个很重要的东西-Behavior,在上面篇文章中,我们其实已经看到过Behavior这个东西了,在AppbarLayout 与NestedScrollView 联动的时候,我们为NestedScrollView设置了一个Behavior, 通过app:layout_behavior="@string/appbar_scrolling_view_behavior",它的值是一个类的全路径,这个Behavior 是Google已经为我们提供的,AppbarLayout的内部类,专门用于处理可滚动View(如:ScrollView、RecyclerView) 与AppbarLayout 联动的。那么这篇文章我们通过介绍Google提供的一些Behavior 的使用场景、使用方式和自定义Behavior 来熟悉和掌握 Behavior。
本文目录:
看一下官方的介绍:Interaction behavior plugin for child views of CoordinatorLayout. 作用于CoordinatorLayout的子View的交互行为插件。一个Behavior 实现了用户的一个或者多个交互行为,它们可能包括拖拽、滑动、快滑或者其他一些手势。
Behavior 是一个顶层抽象类,其他的一些具体行为的Behavior 都是继承自这个类。它提供了几个重要的方法:
解释一下上面几个方法和它们的调用时机:
/**
* 表示是否给应用了Behavior 的View 指定一个依赖的布局,通常,当依赖的View 布局发生变化时
* 不管被被依赖View 的顺序怎样,被依赖的View也会重新布局
* @param parent
* @param child 绑定behavior 的View
* @param dependency 依赖的view
* @return 如果child 是依赖的指定的View 返回true,否则返回false
*/
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
return super.layoutDependsOn(parent, child, dependency);
}
/**
* 当被依赖的View 状态(如:位置、大小)发生变化时,这个方法被调用
* @param parent
* @param child
* @param dependency
* @return
*/
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
return super.onDependentViewChanged(parent, child, dependency);
}
/**
* 当coordinatorLayout 的子View试图开始嵌套滑动的时候被调用。当返回值为true的时候表明
* coordinatorLayout 充当nested scroll parent 处理这次滑动,需要注意的是只有当返回值为true
* 的时候,Behavior 才能收到后面的一些nested scroll 事件回调(如:onNestedPreScroll、onNestedScroll等)
* 这个方法有个重要的参数nestedScrollAxes,表明处理的滑动的方向。
*
* @param coordinatorLayout 和Behavior 绑定的View的父CoordinatorLayout
* @param child 和Behavior 绑定的View
* @param directTargetChild
* @param target
* @param nestedScrollAxes 嵌套滑动 应用的滑动方向,看 {@link ViewCompat#SCROLL_AXIS_HORIZONTAL},
* {@link ViewCompat#SCROLL_AXIS_VERTICAL}
* @return
*/
@Override
public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, View child, View directTargetChild, View target, int nestedScrollAxes) {
return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, nestedScrollAxes);
}
/**
* 嵌套滚动发生之前被调用
* 在nested scroll child 消费掉自己的滚动距离之前,嵌套滚动每次被nested scroll child
* 更新都会调用onNestedPreScroll。注意有个重要的参数consumed,可以修改这个数组表示你消费
* 了多少距离。假设用户滑动了100px,child 做了90px 的位移,你需要把 consumed[1]的值改成90,
* 这样coordinatorLayout就能知道只处理剩下的10px的滚动。
* @param coordinatorLayout
* @param child
* @param target
* @param dx 用户水平方向的滚动距离
* @param dy 用户竖直方向的滚动距离
* @param consumed
*/
@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View child, View target, int dx, int dy, int[] consumed) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
}
/**
* 进行嵌套滚动时被调用
* @param coordinatorLayout
* @param child
* @param target
* @param dxConsumed target 已经消费的x方向的距离
* @param dyConsumed target 已经消费的y方向的距离
* @param dxUnconsumed x 方向剩下的滚动距离
* @param dyUnconsumed y 方向剩下的滚动距离
*/
@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, View child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}
/**
* 嵌套滚动结束时被调用,这是一个清除滚动状态等的好时机。
* @param coordinatorLayout
* @param child
* @param target
*/
@Override
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, View child, View target) {
super.onStopNestedScroll(coordinatorLayout, child, target);
}
/**
* onStartNestedScroll返回true才会触发这个方法,接受滚动处理后回调,可以在这个
* 方法里做一些准备工作,如一些状态的重置等。
* @param coordinatorLayout
* @param child
* @param directTargetChild
* @param target
* @param nestedScrollAxes
*/
@Override
public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, View child, View directTargetChild, View target, int nestedScrollAxes) {
super.onNestedScrollAccepted(coordinatorLayout, child, directTargetChild, target, nestedScrollAxes);
}
/**
* 用户松开手指并且会发生惯性动作之前调用,参数提供了速度信息,可以根据这些速度信息
* 决定最终状态,比如滚动Header,是让Header处于展开状态还是折叠状态。返回true 表
* 示消费了fling.
*
* @param coordinatorLayout
* @param child
* @param target
* @param velocityX x 方向的速度
* @param velocityY y 方向的速度
* @return
*/
@Override
public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, View child, View target, float velocityX, float velocityY) {
return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY);
}
//可以重写这个方法对子View 进行重新布局
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
return super.onLayoutChild(parent, child, layoutDirection);
}
以上就是Behavior的一些重要方法,当我们要自定义一个Behavior的时候,就会去重写上面的一些方法。自定义Behavior 会放在文章最后讲。对Behavior 有了一些了解后,接下来我们看一下Google给我提供了一些特殊场景的Behavior。
BottomSheetBehavior 实现的效果在我们的项目中用的比较多,它就是从底部弹出一个布局,在很多的应用中,分享功能都有这样一个交互。在以前我们通常都是用PopupWindow来搞定,前面也写了一篇文章了,关于PupupWindow的使用和封装,通用PopupWindow,几行代码搞定PopupWindow弹窗,有了BottomSheetBehavior 实现起来就简单一点了。请看效果图:
看看怎么用BottomSheetBehavior:
1,在xml布局文件中为需要从底部弹出的布局绑定BottomSheetBehavior,代码如下:
注意上面这行代码: app:behavior_peekHeight="0dp",peekHeight 属性是设置bottomSheet 折叠时的高度,我们设置为0表示折叠的时候完全隐藏,默认情况时显示布局的高度,布局会显示在界面,所以,如果要一开始布局不显示在界面上的话,需要将peekHeight 设置为0。也可以在代码中设置, 通过sheetBehavior.setPeekHeight(0)。
2,在代码中获取到与布局相关联的BottomSheetBehavior,设置展开与折叠的状态就可以了,BottomSheetBehavior有5种状态:
1, STATE_EXPANDED 展开状态,显示完整布局。
2,STATE_COLLAPSED 折叠状态,显示peekHeigth 的高度,如果peekHeight为0,则全部隐藏,与STATE_HIDDEN效果一样。
3,STATE_DRAGGING 拖拽时的状态
4,STATE_HIDDEN 隐藏时的状态
5,STATE_SETTLING 释放时的状态
看代码:
View shareView = findViewById(R.id.share_view);
//获取BottomSheetBehavior
final BottomSheetBehavior sheetBehavior = BottomSheetBehavior.from(shareView);
//设置折叠时的高度
//sheetBehavior.setPeekHeight(BottomSheetBehavior.PEEK_HEIGHT_AUTO);
//监听BottomSheetBehavior 状态的变化
sheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
}
});
//下滑的时候是否可以隐藏
sheetBehavior.setHideable(true);
findViewById(R.id.btn_show_bottom_sheet).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(sheetBehavior.getState() != BottomSheetBehavior.STATE_EXPANDED){
sheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}else {
sheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
}
});
代码很简单,重要的就是通过方法 sheetBehavior.setState()来改变状态,是显示还是隐藏。其他的几个方法都添加了注释,不用多讲。
上面说了BottomSheetBehavior, 接下来看一下BottomSheetDialog, 一看名字就知道,它就是一个Dialog,使用方法和Dialog 一样,它是对BootomSheetBehavior 进行了包装,从底部弹出一个Dialog。BottomSheetDialog 使用起来比BottomSheetBahvior更方便,效果更佳。看一下它的源码也非常简单,就是Dialog 显示的布局绑定了BottomSheeBehavior,源码如下:
private View wrapInBottomSheet(int layoutResId, View view, ViewGroup.LayoutParams params) {
final CoordinatorLayout coordinator = (CoordinatorLayout) View.inflate(getContext(),
R.layout.design_bottom_sheet_dialog, null);
if (layoutResId != 0 && view == null) {
view = getLayoutInflater().inflate(layoutResId, coordinator, false);
}
FrameLayout bottomSheet = (FrameLayout) coordinator.findViewById(R.id.design_bottom_sheet);
mBehavior = BottomSheetBehavior.from(bottomSheet);
mBehavior.setBottomSheetCallback(mBottomSheetCallback);
mBehavior.setHideable(mCancelable);
if (params == null) {
bottomSheet.addView(view);
} else {
bottomSheet.addView(view, params);
}
// We treat the CoordinatorLayout as outside the dialog though it is technically inside
coordinator.findViewById(R.id.touch_outside).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (mCancelable && isShowing() && shouldWindowCloseOnTouchOutside()) {
cancel();
}
}
});
return coordinator;
}
就这样一个方法,获取到Behavior,设置了一个监听状态的回调,设置了下滑可以隐藏。然后将Dialog 显示的布局添加到了绑定了BottomSheetBehavior 的ViewGroup 里。这个方法在setContent()方法被调用:
@Override
public void setContentView(View view) {
super.setContentView(wrapInBottomSheet(0, view, null));
}
@Override
public void setContentView(View view, ViewGroup.LayoutParams params) {
super.setContentView(wrapInBottomSheet(0, view, params));
}
接下来看一下使用方法,非常简单,以网易云音乐的歌单和分享UI为例:
网易云音乐歌单UI效果 如下:
来张gif图效果更清楚:
本文通过BottomSheetDialog 实现的效果图如下:
歌单代码如下:
private void showBottomSheetDialog(){
BottomSheetDialog dialog = new BottomSheetDialog(this);
View view = LayoutInflater.from(this).inflate(R.layout.bottom_sheet_dialog,null);
handleList(view);
dialog.setContentView(view);
dialog.setCancelable(true);
dialog.setCanceledOnTouchOutside(true);
dialog.show();
}
private void handleList(View contentView){
RecyclerView recyclerView = (RecyclerView) contentView.findViewById(R.id.recyclerView);
LinearLayoutManager manager = new LinearLayoutManager(this);
manager.setOrientation(LinearLayoutManager.VERTICAL);
recyclerView.setLayoutManager(manager);
MusicAdapter adapter = new MusicAdapter();
recyclerView.setAdapter(adapter);
adapter.setData(mockData());
adapter.notifyDataSetChanged();
}
分享代码如下:
/**
* share Dialog
*/
private void showShareDialog(){
if(mBottomSheetDialog == null){
mBottomSheetDialog = new BottomSheetDialog(this);
View view = LayoutInflater.from(this).inflate(R.layout.bottom_sheet_share_dialog,null);
mBottomSheetDialog.setContentView(view);
mBottomSheetDialog.setCancelable(true);
mBottomSheetDialog.setCanceledOnTouchOutside(true);
// 解决下滑隐藏dialog 后,再次调用show 方法显示时,不能弹出Dialog
View view1 = mBottomSheetDialog.getDelegate().findViewById(android.support.design.R.id.design_bottom_sheet);
final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(view1);
bottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
Log.i("BottomSheet","onStateChanged");
mBottomSheetDialog.dismiss();
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
}
});
}else{
mBottomSheetDialog.show();
}
}
代码很简单,和其他普通Dialog的用法一样。值的主意的一点是这里有个bug ,那就是当你下滑隐藏了Dialog 之后,下次直接调用show方法来显示Dialog时(没有重新new 的情况下),Dialog不能显示,原因是因为BottomSheetDialog 源码中,关闭的Dialog 是依赖BottomSheetBehavior 的,当下滑隐藏的时候,BottomSheet的状态也为STATE_HIDDEN,并且同时dismiss Dialog,下次show 的时候,是没有办法显示一个状态为STATE_HIDDEN 的布局的。 网上搜了一下,有很多人都碰到过,解决方法来自这篇文章Material之Behavior实现支付宝密码弹窗 仿淘宝/天猫商品属性选择, 解决思路:获取到BottomSheetDialog 的布局,然后拿到绑定的BottomSheetBehavior,重新设置监听,在调用dismiss 方法时,我们重新设置一些Behavior 的状态。代码如下:
// 解决下滑隐藏dialog 后,再次调用show 方法显示时,不能弹出Dialog
View view1 = mBottomSheetDialog.getDelegate().findViewById(android.support.design.R.id.design_bottom_sheet);
final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(view1);
bottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
Log.i("BottomSheet","onStateChanged");
mBottomSheetDialog.dismiss();
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
}
});
以上就是BottomSheetBehavior 和BottomSheetDialog 的用法。
上面讲了BottomSheetBehavior 和BottomSheetDialog 的用法,接下来看另一种场景的Behavior-SwipeDissmissBehavior,叫滑动消失或者滑动关闭,这个Behavior 在我们项目中用得可能就不是很多了。有个场景就是Snackbar的使用了,Android 5.0 以上 ,增加了Snackbar提示消息,Snackbar 的Behavior 的就是 SwipeDissmissBehavior 的应用,当滑动Snackbar 的时候,Snackbar 消失,效果如下:
使用也非常简单,在代码中只接new 一个SwipeDismissBehavior,设置一些属性后,添加到CoordinatorLayout.LayoutParams,代码如下:
mSwipeLayout = findViewById(R.id.swipe_layout);
SwipeDismissBehavior swipe = new SwipeDismissBehavior();
/**
* //设置滑动的方向,有3个值
*
* 1,SWIPE_DIRECTION_ANY 表示向左像右滑动都可以,
* 2,SWIPE_DIRECTION_START_TO_END,只能从左向右滑
* 3,SWIPE_DIRECTION_END_TO_START,只能从右向左滑
*/
swipe.setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END);
swipe.setStartAlphaSwipeDistance(0f);
swipe.setSensitivity(0.2f);
swipe.setListener(new SwipeDismissBehavior.OnDismissListener() {
@Override
public void onDismiss(View view) {
Log.e(TAG,"------>onDissmiss");
}
@Override
public void onDragStateChanged(int state) {
Log.e(TAG,"------>onDragStateChanged");
}
});
CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) mSwipeLayout.getLayoutParams();
if(layoutParams!=null){
layoutParams.setBehavior(swipe);
}
有两个重要的方法,wipe.setSwipeDirection
设置滑动方向,有三个取值,上面已经注释,不过多解释,还有就是swipe.setListener
可以监听dissmiss 和状态改变,在这些回调里面可以做一些自己的逻辑。最后效果图:
上面讲了Google 为我们提供的一些场景使用的Behavior,当然还有一些Google 提供的一些组件使用的Behavior,AppbarLayout内部的Behavior,如专门协调 AppbarLayout 与可滚动View(NestedScrollView,RecyclerView )的, FloatActionButton内部的Behavior ,协调和Snackbar 的关系,保证Snackbar 弹出的时候不被FAB 遮挡。还有就是上面说的Snackbar内部的Behavior 等等。但是有时候,要实现多个View之间的的交互时,我们可以自定义Behavior ,下面就说说怎么自定义一个Behavior。
自定义Behavior 最关键的就是文章第一部分介绍的Behavior 提供的那一些方法,忘了的请到回去看一下第一部分的方法注释。自定义Behavior 分为两种:
第一种是通过监听一个View的状态,如位置、大小的变化,来改变其他View的行为,这种只需要重写2个方法就可以了,分别是layoutDependsOn
和onDependentViewChanged
, layoutDependsOn方法判断是指定依赖的View时,返回true,然后在onDependentViewChanged 里,被依赖的View做需要的行为动作。
第二种就是重写onStartNestedScroll
、onNestedPreScroll
、onNestedScroll
等一系列方法,前面第一步分已经讲过。
上面两种方法相比,第一种很简单,第二种复杂一些,但是第二种实现的效果也要复杂。下面就以开眼首页的滑动Header效果为例,来实现一个自定义的Behavior。开眼首页滑动header效果如下:
效果如上:就是列表滑动的时候是覆盖Header(不是Header缩小,Header没动),然后就是Header有一个alpha 的变化。
1,首先是整个布局,Header 固定在顶部,列表在Header 的下方,CoordinatorLayout 是一个FrameLayout,不能提供这样的布局,我们需要重写onLayoutChild 来让列表位于Header下面:
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
Log.i(TAG,"onLayoutChild.....");
CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
if(params!=null && params.height == CoordinatorLayout.LayoutParams.MATCH_PARENT){
child.layout(0,0,parent.getWidth(),parent.getHeight());
child.setTranslationY(getHeaderHeight());
return true;
}
return super.onLayoutChild(parent, child, layoutDirection);
}
我们需要知道Header的高度,将Header的高度写在dimens文件中,getHeaderHeight()方法如下:
/**
* 获取Header 高度
* @return
*/
public int getHeaderHeight(){
return MaterialDesignSimpleApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.header_height);
}
2,当开始滑动的时候,利用setTranslationY 来移动列表,知道完全盖住header ,这是时候,列表就不移动了,只是列表的滑动了。当下滑到顶端的时候,又将列表向下滑动,直到header 完全显示,思路就是这样。开眼的首页向上滑洞的时候,Header 有一个alpha的变化,本例子没有实现,其实也很简单,只要重写onDependentViewChanged方法,在里面根据滑动距离算出alpha 变化的值就可以了。自定义Behavior 完整代码如下:
/**
*
* 自定义Behavior :实现RecyclerView(或者其他可滑动View,如:NestedScrollView) 滑动覆盖header 的效果
* Created by zhouwei on 16/12/19.
*/
public class CoverHeaderScrollBehavior extends CoordinatorLayout.Behavior {
public static final String TAG = "CoverHeaderScroll";
public CoverHeaderScrollBehavior(Context context, AttributeSet attributeSet){
super(context,attributeSet);
}
@Override
public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) {
Log.i(TAG,"onLayoutChild.....");
CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) child.getLayoutParams();
if(params!=null && params.height == CoordinatorLayout.LayoutParams.MATCH_PARENT){
child.layout(0,0,parent.getWidth(),parent.getHeight());
child.setTranslationY(getHeaderHeight());
return true;
}
return super.onLayoutChild(parent, child, layoutDirection);
}
@Override
public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, View child, View directTargetChild, View target, int nestedScrollAxes) {
return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, View child, View target, int dx, int dy, int[] consumed) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
// 在这个方法里面只处理向上滑动
if(dy < 0){
return;
}
float transY = child.getTranslationY() - dy;
Log.i(TAG,"transY:"+transY+"++++child.getTranslationY():"+child.getTranslationY()+"---->dy:"+dy);
if(transY > 0){
child.setTranslationY(transY);
consumed[1]= dy;
}
}
@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, View child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
// 在这个方法里只处理向下滑动
if(dyUnconsumed >0){
return;
}
float transY = child.getTranslationY() - dyUnconsumed;
Log.i(TAG,"------>transY:"+transY+"****** child.getTranslationY():"+child.getTranslationY()+"--->dyUnconsumed"+dxUnconsumed);
if(transY > 0 && transY < getHeaderHeight()){
child.setTranslationY(transY);
}
}
/**
* 获取Header 高度
* @return
*/
public int getHeaderHeight(){
return MaterialDesignSimpleApplication.getAppContext().getResources().getDimensionPixelOffset(R.dimen.header_height);
}
}
xml 的代码如下:
最后实现的效果如下:
以上就是关于Behavior 的全部内容,自定义Behavior 这一块,特别是处理滑动嵌套对于刚接触的同学来说还是挺难的,不过当掌握了之后,我们能做出很多炫酷的效果。所以,再困难也值得花时间去学习。本文到此结束,如有问题,欢迎交流。所有关于Material Design 的使用示例都在这里:MaterialDesignSamples
参考资料:
1,自定义Behavior的艺术探索-仿UC浏览器主页
2,使用 CoordinatorLayout 实现复杂联动效果
3,Material之Behavior实现支付宝密码弹窗 仿淘宝/天猫商品属性选择