写这篇文章的初衷来自最近项目中的一个需求,查阅了网上的一些资料,貌似大家都热衷于用ScrollView+HeaderView去实现,根据手势判断,去做图片的矩阵放大,然后不断的让ImageView去requestLayout重新计算高度,也不是说不行,只是当布局嵌套层级多的时候,这种重复测量的方式,性能上会出现问题的,好了,话不多说,直接进入正题。
这里再补充一下,之前写过一篇《高仿美团APP页面滑动标题栏渐变效果》有一些朋友问我另类的实现方式和其它的适配问题,这几天会抽时间修整并同步到github,当然更推荐用本篇文章的实现方式去做。
CoordinatorLayout是Google在android.support.design包中提供的一个新的视图布局,它继承于ViewGroup,其布局方式类似于FrameLayout,同时引入了一套全新的事件处理方式Behavior,它允许开发者自定义Behavior来实现一些复杂的UI交互效果,通过组合和代理模式将View的事件处理逻辑抽取出来,达到更漂亮的松散耦合。
下面来看下我们今天要实现的最终效果图:
认识CoordinatorLayout
CoordinatorLayout是一个“布局协调者”,用来协调布局内子View之间的关系(状态,大小,位置等),可以让开发者灵活的定制协调规则。
关于子View之间的协调规则,我们需要用到上文提到的Behavior,它是CoordinatorLayout类下的一个抽象类,在实现类中需要指定一个泛型View,这个泛型View也就是CoordinatorLayout下所作用的子View,通常情况下,我们指定View即可。
public static abstract class Behavior {
...
}
还有一点很重要,当我们在自定义Behavior的时候,一定要覆写带参的构造方法,因为在CoordinatorLayout源码中parseBehavior是通过反射来调用这个构造方法的,关键代码如下:
public Behavior(Context context, AttributeSet attrs) {
}
static final Class>[] CONSTRUCTOR_PARAMS = new Class>[] {
Context.class,
AttributeSet.class
};
final Class clazz = (Class) Class.forName(fullName, true,
context.getClassLoader());
c = clazz.getConstructor(CONSTRUCTOR_PARAMS);
c.setAccessible(true);
constructors.put(fullName, c);
下面来说下如何制定协调规则,我们可以把“布局协调”分成两大类:
1、View之间的相互关系
2、View与滑动之间的相互关系
View之间的相互关系
既然是View之间的相互关系,那么这里的View肯定不会只有一个,也从中引出了“作用View”和“被依赖View”的概念,作用View随着被依赖View状态的变化而变化,有点类似于观察模式中的观察者和被观察者(当被观察者的状态发生变化,通知观察者,观察者也要做出对应状态的变化),这里我们需要关注layoutDependsOn与onDependentViewChanged两个方法:
layoutDependsOn:作用View是否依赖被依赖View,依赖为true,不依赖为false。
onDependentViewChanged:当被依赖View发生状态变化时,作用View应该跟随做出怎么样的变化。
举个例子,我们设置2个TextView,让其中一个TextView(作用View)随着另一个TextView(被依赖View)位置的移动而移动,效果图如下:
findViewById(R.id.tv_layout_dependency).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
ViewCompat.offsetTopAndBottom(v, 30);
}
});
package com.lcw.coordinatorlayout;
import android.content.Context;
import android.support.design.widget.CoordinatorLayout;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.View;
/**
* 跟随所依赖的View的Y轴移动
* Create by: chenwei.li
* Date: 2018/5/20
* Time: 上午10:39
* Email: [email protected]
*/
public class MoveViewBehavior extends CoordinatorLayout.Behavior {
public MoveViewBehavior() {
}
/**
* 需要重写构造方法,在CoordinatorLayout源码中是通过反射拿到Behavior的
*
* @param context
* @param attrs
*/
public MoveViewBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* 确定是否依赖dependency
*
* @param parent
* @param child
* @param dependency
* @return
*/
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
//return dependency instanceof TextView;
return dependency.getId() == R.id.tv_layout_dependency;
}
/**
* 如果确定依赖dependency,那么child跟随dependency需要做出什么变化(child的位置,大小,状态等)
*
* @param parent
* @param child
* @param dependency
* @return
*/
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
int offsetY = dependency.getTop() - child.getTop();
ViewCompat.offsetTopAndBottom(child, offsetY);
return super.onDependentViewChanged(parent, child, dependency);
}
}
我们简单分析一下,我们自定义了一个Behavior,让其继承CoordinatorLayout.Behavior并指定泛型为通用View(TextView也可以),然后覆写它的构造方法,在layoutDependsOn方法中,我们对View的id(或类型)进行判断,确定被依赖的View,这里的child指作用View,dependency指被依赖的View,然后我们在onDependentViewChanged方法中,让作用View跟随被依赖View做出位置调整,也就是child跟随dependency位置的移动而移动,然后xml里就非常简单了,我们指定app:layout_behavior为我们的自定义Behavior,并作用在作用View上,这里需要注意的是,指定app:layout_behavior属性的一定要是CoordinatorLayout的直接子View才会生效,因为这个属性存在于CoordinatorLayout.LayoutParams里。
通过上面的操作我们让View的逻辑操作与业务解耦,我们不需要在activity或者fragment里再去写一些关于View与View之间的关系事件,我们只需要定义好Behavior,然后在xml布局文件中配置即可,当然这些还不够,我们继续往下看。
View与滑动之间的关系
开门见山,先介绍基础的2个方法,onStartNestedScroll和onNestedPreScroll:
onStartNestedScroll:是否要让Behavior处理滑动,处理返回true,不处理返回false,如果返回为false则不走Behavior剩下的方法。
onNestedPreScroll:具体怎么处理滑动的方法。
举个例子,我们设置2个ScrollView,让其中一个ScrollView(随着另一个ScrollView滑动而滑动,效果图如下:
package com.lcw.coordinatorlayout;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.design.widget.CoordinatorLayout;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.View;
/**
* 跟随所依赖的View滚动
* Create by: chenwei.li
* Date: 2018/5/20
* Time: 上午10:39
* Email: [email protected]
*/
public class ScrollerViewBehavior extends CoordinatorLayout.Behavior {
public ScrollerViewBehavior() {
}
/**
* 需要重写构造方法,在CoordinatorLayout源码中是通过反射拿到Behavior的
*
* @param context
* @param attrs
*/
public ScrollerViewBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* 是否要处理滑动
*
* @param coordinatorLayout
* @param child
* @param directTargetChild
* @param target
* @param axes
* @param type
* @return
*/
@Override
public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) {
return true;
}
/**
* 具体滑动处理
* @param coordinatorLayout
* @param child
* @param target
* @param dx
* @param dy
* @param consumed
* @param type
*/
@Override
public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
int offsetY = target.getScrollY();
child.setScrollY(offsetY);
}
}
我们在onStartNestedScroll方法中返回true,代表我们要处理滑动,然后我们在onNestedPreScroll方法中监听target(被依赖View)在Y轴的滑动距离,然后让其child(作用View)也跟随滑动。
关于滑动所涉及的方法还有很多,一起来看下:
onStartNestedScroll:当手指按下屏幕的时候触发,用来决定是否要让Behavior处理这次滑动,true为处理,false为不处理,如果不处理,那么Behavior的后续方法也就不会在再调用了,方法中也提供了一些辅助参数,比如type,可以用来判断用户动作,比如是TYPE_TOUCH按住屏幕拖动,TYPE_NON_TOUCH快速拉动屏幕等。
onNestedScrollAccepted:在Behavior处理这次滑动前调用(onStartNestedScroll返回true),可以在这里做一些初始化操作。
onNestedPreScroll:滑动即将开始,这个方法有个参数 int[] consumed,可以用来表示做了多少位移,假设用户滑动了100px,你做了 90px 的位移,那么就需要把 consumed[1] 改成 90(下标 0、1 分别对应 x、y 轴),这样就可以让后续的方法去处理这10px。
onNestedScroll:上一个方法结束后,剩下的滑动位移(dxUnconsumed、dyUnconsumed)未处理的,可以在这里处理。
onNestedPreFling:当用户快速滑动屏幕,产生惯性滑动的时候,会触发此方法,这个方法参数中提供了滑动方向与速度。
onStopNestedScroll:滑动停止的时候调用,如果没有发生惯性滑动,那么会直接到这个方法。
以上这些方法不需要都覆写,可以我们根据需要,灵活使用即可。
好了,到这里我们对CoordinatorLayout的基础知识就已经讲完了,接下来我们结合AppBarLayout、CollapsingToolbarLayout、Toolbar打造我们绚丽酷炫的顶部栏效果吧。
首先我们来实现一个基础的Material Design效果(不带下拉越界弹性放大ImageView),也是Google在开发者大会上给我们示范的效果,来看下效果图:
先来看下xml布局:
有点懵,一下子多出这么控件和属性,不着急,我们从上往下一个个解释:
CoordinatorLayout: 这个上文已经说的够多了,就不再做过多阐述。
AppBarLayout:它继承于LinearLayout,布局方向为垂直,可以把它当成垂直布局方向的LinearLayout来用,它扩展一些功能,比如它可以监听到某个View的滚动状态,然后通知它布局内的子View做出相对应的状态改变,而这个子View应该如何变化取决于子View的layout_scrollFlags属性设置,这里有scroll、enterAlways、enterAlwaysCollapsed、exitUntilCollapsed、snap5种属性值,后面四种作用于第一种之上,也就是不能离开scroll单独存在,我们分别来看下:
scroll:当属性值为scroll的时候,会随着NestedScrollView的滚动一起滚动(好像是ScrollView的HeaderView一样,融为一体)
enterAlways: 当属性值为scroll|enterAlways的时候,当NestedScrollView往下滚的时候,先响应View的滚动,再响应NestedScrollView的往下滚动。
exitUntilCollapsed:当属性值为scroll|exitUntilCollapsed的 时候,在往上滚动的时候,会优先把View缩小为最小高度(minHeight),然后再响应NestedScrollView的滚动。
enterAlwaysCollapsed:当属性值为scroll|enterAlways| enterAlwaysCollapsed的时候,在往下滚的时候,会先把最小高度的View展示出来,然后等NestedScrollView向下滚动结束,才继续响应View的滚动事件,撑开为View的最大高度。
snap:这是一个滚动比例设置,类似于ViewPager滑动比例,来决定它是否切换页面,而这里就是来确定是否滚出布局。
可能有朋友会问,为什么AppbarLayout可以知道NestedScrollView的滑动状态,请看NestedScrollView布局中的Behavior(appbar_scrolling_view_behavior),这个是Google为我们提供的(AppbarLayout类下的ScrollingViewBehavior),至于原理相信认真读过上文的你应该可以知道了,有兴趣的朋友自己看下源码实现吧。
Toolbar:简单点来说,它是用来取代ActionBar的,但是它比ActionBar更强大,除了完成ActionBar能实现的功能,它还可以随意定制位置,配合CollapsingToolbarLayout还可以展现出更强大的功能。
CollapsingToolbarLayout:是对Toolbar再一层包装的ViewGroup,用来实现折叠效果,需要作为AppbarLayout的直接子View,它具备如下功能:
折叠Title(Collapsing title):当布局内容全部显示出来时,title是最大的,但是随着View逐步移出屏幕顶部,title变得越来越小。你可以通过调用setTitle函数来设置title。
内容纱布(Content scrim):根据滚动的位置是否到达一个阀值,来决定是否对View“盖上纱布”。可以通过setContentScrim(Drawable)来设置纱布的图片.
状态栏纱布(Status bar scrim):根据滚动位置是否到达一个阀值决定是否对状态栏“盖上纱布”,你可以通过setStatusBarScrim(Drawable)来设置纱布图片,但是只能在LOLLIPOP设备上面有作用。
视差滚动子View(Parallax scrolling children):子View可以选择在当前的布局当时是否以“视差”的方式来跟随滚动。(PS:其实就是让这个View的滚动的速度比其他正常滚动的View速度稍微慢一点)。将布局参数app:layout_collapseMode设为parallax。
将子View位置固定(Pinned position children):子View可以选择是否在全局空间上固定位置,这对于Toolbar来说非常有用,因为当布局在移动时,可以将Toolbar固定位置而不受移动的影响。 将app:layout_collapseMode设为pin。
好了,到这里就全部介绍完了,我们回头看下刚才的布局文件,首先根布局是CoordinatorLayout,用来协调子View(AppBarLayout和RecyclerView),AppBarLayout包裹CollapsingToolbarLayout,而CollapsingToolbarLayout包裹ToolBar形成一个增强型的Toolbar(包含图片和效果遮罩)指定了滚动行为scrollFlags为scroll|exitUntilCollapsed, 指定了内容纱布contentScrim为具体颜色值,图片设置了滚动视差collapseMode为parallax,Toolbar指定了collapseMode为pin,各属性值的具体含义,这里不再重复解释。
学了大半天的自定义Behavior没有派上用场,心里痒痒的,现在我们就来实现一个基于官方给的基础Material Design效果的扩展,实现AppbarLayout滑动到底后继续往下拉,越界放大图片的效果。
这里是xml布局文件,基本和上面的官方示例一样,这里设置ImageView包括它的父布局fitsSystemWindows为true,是为了可以让内容延伸到系统状态栏,让其更加美观,然后重点就在于AppbarLayout设置里的Behavior了。
package com.lcw.ui.widget;
import android.animation.ValueAnimator;
import android.content.Context;
import android.support.design.widget.AppBarLayout;
import android.support.design.widget.CoordinatorLayout;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import com.lcw.fun.shareforgank.R;
/**
* 头部下拉放大Behavior
* Create by: chenwei.li
* Date: 2018/5/26
* Time: 下午9:54
* Email: [email protected]
*/
public class AppbarZoomBehavior extends AppBarLayout.Behavior {
private ImageView mImageView;
private int mAppbarHeight;//记录AppbarLayout原始高度
private int mImageViewHeight;//记录ImageView原始高度
private static final float MAX_ZOOM_HEIGHT = 500;//放大最大高度
private float mTotalDy;//手指在Y轴滑动的总距离
private float mScaleValue;//图片缩放比例
private int mLastBottom;//Appbar的变化高度
private boolean isAnimate;//是否做动画标志
public AppbarZoomBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onLayoutChild(CoordinatorLayout parent, AppBarLayout abl, int layoutDirection) {
boolean handled = super.onLayoutChild(parent, abl, layoutDirection);
init(abl);
return handled;
}
/**
* 进行初始化操作,在这里获取到ImageView的引用,和Appbar的原始高度
*
* @param abl
*/
private void init(AppBarLayout abl) {
abl.setClipChildren(false);
mAppbarHeight = abl.getHeight();
mImageView = (ImageView) abl.findViewById(R.id.iv_img);
if (mImageView != null) {
mImageViewHeight = mImageView.getHeight();
}
}
/**
* 是否处理嵌套滑动
*
* @param parent
* @param child
* @param directTargetChild
* @param target
* @param nestedScrollAxes
* @param type
* @return
*/
@Override
public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes, int type) {
isAnimate = true;
return true;
}
/**
* 在这里做具体的滑动处理
*
* @param coordinatorLayout
* @param child
* @param target
* @param dx
* @param dy
* @param consumed
* @param type
*/
@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dx, int dy, int[] consumed, int type) {
if (mImageView != null && child.getBottom() >= mAppbarHeight && dy < 0 && type == ViewCompat.TYPE_TOUCH) {
zoomHeaderImageView(child, dy);
} else {
if (mImageView != null && child.getBottom() > mAppbarHeight && dy > 0 && type == ViewCompat.TYPE_TOUCH) {
consumed[1] = dy;
zoomHeaderImageView(child, dy);
} else {
if (valueAnimator == null || !valueAnimator.isRunning()) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
}
}
}
}
/**
* 对ImageView进行缩放处理,对AppbarLayout进行高度的设置
*
* @param abl
* @param dy
*/
private void zoomHeaderImageView(AppBarLayout abl, int dy) {
mTotalDy += -dy;
mTotalDy = Math.min(mTotalDy, MAX_ZOOM_HEIGHT);
mScaleValue = Math.max(1f, 1f + mTotalDy / MAX_ZOOM_HEIGHT);
ViewCompat.setScaleX(mImageView, mScaleValue);
ViewCompat.setScaleY(mImageView, mScaleValue);
mLastBottom = mAppbarHeight + (int) (mImageViewHeight / 2 * (mScaleValue - 1));
abl.setBottom(mLastBottom);
}
/**
* 处理惯性滑动的情况
*
* @param coordinatorLayout
* @param child
* @param target
* @param velocityX
* @param velocityY
* @return
*/
@Override
public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, float velocityX, float velocityY) {
if (velocityY > 100) {
isAnimate = false;
}
return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY);
}
/**
* 滑动停止的时候,恢复AppbarLayout、ImageView的原始状态
*
* @param coordinatorLayout
* @param abl
* @param target
* @param type
*/
@Override
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout abl, View target, int type) {
recovery(abl);
super.onStopNestedScroll(coordinatorLayout, abl, target, type);
}
ValueAnimator valueAnimator;
/**
* 通过属性动画的形式,恢复AppbarLayout、ImageView的原始状态
*
* @param abl
*/
private void recovery(final AppBarLayout abl) {
if (mTotalDy > 0) {
mTotalDy = 0;
if (isAnimate) {
valueAnimator = ValueAnimator.ofFloat(mScaleValue, 1f).setDuration(220);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
ViewCompat.setScaleX(mImageView, value);
ViewCompat.setScaleY(mImageView, value);
abl.setBottom((int) (mLastBottom - (mLastBottom - mAppbarHeight) * animation.getAnimatedFraction()));
}
});
valueAnimator.start();
} else {
ViewCompat.setScaleX(mImageView, 1f);
ViewCompat.setScaleY(mImageView, 1f);
abl.setBottom(mAppbarHeight);
}
}
}
}
我们自定义了Behavior,由于是作用在AppbarLayout的,所以我们对应继承实现AppbarLayout下的Behavior抽象类,然后覆写带参的构造方法,在AppbarLayout布局的时候会调用onLayoutChild方法 ,所以我们可以在这里获取ImageView和AppbarLayout的原始高度,方便后面做对比和恢复状态操作,然后在onNestedPreScroll方法里做具体的嵌套滑动处理,当AppbarLayout当前的高度大于或者等于原始高度且手指向下滑动(dy<0)且为手指按住屏幕拖动的时候,我们对ImageView做放大操作同时使AppbarLayout的高度变大,反之,当满足其他条件且手指的方向是相反(往上滑),则让ImageView做缩小操作同时使AppbarLayout的高度恢复,然后我们在onStopNestedScroll方法里处理滑动停止时候的状态,这里利用属性动画让高度恢复,这里在fling(惯性滑动)中进行了一个速度限制,这个是避免当用户滑动的时候手抖出发快速滑动,导致频繁触发属性动画出现的位置显示错误。
好了,到这里内容就结束了,有什么疑问,欢迎大家在评论给我留言~
源码下载:
这里附上源码地址(欢迎Star,欢迎Fork):整理中,稍后放出。