Android开发之CoordinatorLayout打造滑动越界弹性放大图片效果

写这篇文章的初衷来自最近项目中的一个需求,查阅了网上的一些资料,貌似大家都热衷于用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)位置的移动而移动,效果图如下:


Android开发之CoordinatorLayout打造滑动越界弹性放大图片效果_第1张图片



    

    


   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滑动而滑动,效果图如下:


Android开发之CoordinatorLayout打造滑动越界弹性放大图片效果_第2张图片



    

        
    

    

        
    


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在开发者大会上给我们示范的效果,来看下效果图:


基础的Material Design效果

先来看下xml布局:




    

        

            

            

        

    

    


有点懵,一下子多出这么控件和属性,不着急,我们从上往下一个个解释:

CoordinatorLayout: 这个上文已经说的够多了,就不再做过多阐述。

AppBarLayout:它继承于LinearLayout,布局方向为垂直,可以把它当成垂直布局方向的LinearLayout来用,它扩展一些功能,比如它可以监听到某个View的滚动状态,然后通知它布局内的子View做出相对应的状态改变,而这个子View应该如何变化取决于子View的layout_scrollFlags属性设置,这里有scroll、enterAlways、enterAlwaysCollapsed、exitUntilCollapsed、snap5种属性值,后面四种作用于第一种之上,也就是不能离开scroll单独存在,我们分别来看下:




    

        

    

    

        

    



scroll:当属性值为scroll的时候,会随着NestedScrollView的滚动一起滚动(好像是ScrollView的HeaderView一样,融为一体)

app:layout_scrollFlags="scroll"

enterAlways: 当属性值为scroll|enterAlways的时候,当NestedScrollView往下滚的时候,先响应View的滚动,再响应NestedScrollView的往下滚动。

app:layout_scrollFlags="scroll|enterAlways"

exitUntilCollapsed:当属性值为scroll|exitUntilCollapsed的 时候,在往上滚动的时候,会优先把View缩小为最小高度(minHeight),然后再响应NestedScrollView的滚动。

app:layout_scrollFlags="scroll|exitUntilCollapsed"

enterAlwaysCollapsed:当属性值为scroll|enterAlways| enterAlwaysCollapsed的时候,在往下滚的时候,会先把最小高度的View展示出来,然后等NestedScrollView向下滚动结束,才继续响应View的滚动事件,撑开为View的最大高度。

app:layout_scrollFlags="scroll|enterAlways|enterAlwaysCollapsed"

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):整理中,稍后放出。

你可能感兴趣的:(Android开发之CoordinatorLayout打造滑动越界弹性放大图片效果)