UI之自定义Behavior实现AppBarLayout越界弹性效果
TheronCharl
8年前
<h3>先上效果</h3> <p style="text-align:center"><img src="https://simg.open-open.com/show/6f05d780340c23ab1ff0257106fd872d.gif"></p> <h3>一、继承 AppBarLayout.Behavior</h3> <p>AppBarLayout有一个默认的 Behavior ,即 AppBarLayout.Behavior , AppBarLayout.Behavior 已注解的方式设置给AppBarLayout。</p> <pre> <code class="language-java">@CoordinatorLayout.DefaultBehavior(AppBarLayout.Behavior.class) public class AppBarLayout extends LinearLayout { ... }</code></pre> <p>1.继承 AppBarLayout.Behavior 自定义 Behavior</p> <p>我们可以继承 AppBarLayout.Behavior 并重新设置给AppBarLayout来修改AppBarLayout的默认滚动行为,实现AppBarLayout的弹性越界效果就可以通过这种方式实现。</p> <p>继承 AppBarLayout.Behavior 需要重写构造方法</p> <pre> <code class="language-java">public class AppBarLayoutOverScrollViewBehavior extends AppBarLayout.Behavior { public AppBarLayoutOverScrollViewBehavior() { } public AppBarLayoutOverScrollViewBehavior(Context context, AttributeSet attrs) { super(context, attrs); } }</code></pre> <p>2.将自定义的 Behavior 设置给AppBarLayout</p> <p>可以通过两种方式将自定义的 Behavior 设置给AppBarLayout</p> <ol> <li> <p>在布局文件中设置</p> <pre> <code class="language-java"><android.support.design.widget.AppBarLayout ... app:layout_behavior="packageName.AppBarLayoutOverScrollViewBehavior"> </android.support.design.widget.AppBarLayout></code></pre> </li> <li> <p>在代码中设置</p> <pre> <code class="language-java">AppBarLayout appBar = (AppBarLayout) findViewById(R.id.appbar); CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.setBehavior(new AppBarLayoutOverScrollViewBehavior()); appBar.setLayoutParams(params);</code></pre> </li> </ol> <p>设置完成后,自定义的 Behavior 就会生效,但是因为没有重写任何方法,所以AppBarLayout的滚动行为不会发生变化。</p> <h3>二、 Behavior 中的回调方法分析</h3> <p>将自定义的 Behavior 设置给AppBarLayout后,可以在自定义的 Behavior 中重写滚动相关回调方法</p> <pre> <code class="language-java">public class AppBarLayoutOverScrollViewBehavior extends AppBarLayout.Behavior { ... /** * AppBarLayout布局时调用 * * @param parent 父布局CoordinatorLayout * @param abl 使用此Behavior的AppBarLayout * @param layoutDirection 布局方向 * @return 返回true表示子View重新布局,返回false表示请求默认布局 */ @Override public boolean onLayoutChild(CoordinatorLayout parent, AppBarLayout abl, int layoutDirection) { return super.onLayoutChild(parent, abl, layoutDirection); } /** * 当CoordinatorLayout的子View尝试发起嵌套滚动时调用 * * @param parent 父布局CoordinatorLayout * @param child 使用此Behavior的AppBarLayout * @param directTargetChild CoordinatorLayout的子View,或者是包含嵌套滚动操作的目标View * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) * @param nestedScrollAxes 嵌套滚动的方向 * @return 返回true表示接受滚动 */ @Override public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes) { return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes); } /** * 当嵌套滚动已由CoordinatorLayout接受时调用 * * @param coordinatorLayout 父布局CoordinatorLayout * @param child 使用此Behavior的AppBarLayout * @param directTargetChild CoordinatorLayout的子View,或者是包含嵌套滚动操作的目标View * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) * @param nestedScrollAxes 嵌套滚动的方向 */ @Override public void onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes) { super.onNestedScrollAccepted(coordinatorLayout, child, directTargetChild, target, nestedScrollAxes); } /** * 当准备开始嵌套滚动时调用 * * @param coordinatorLayout 父布局CoordinatorLayout * @param child 使用此Behavior的AppBarLayout * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) * @param dx 用户在水平方向上滑动的像素数 * @param dy 用户在垂直方向上滑动的像素数 * @param consumed 输出参数,consumed[0]为水平方向应该消耗的距离,consumed[1]为垂直方向应该消耗的距离 */ @Override public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dx, int dy, int[] consumed) { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed); } /** * 嵌套滚动时调用 * * @param coordinatorLayout 父布局CoordinatorLayout * @param child 使用此Behavior的AppBarLayout * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) * @param dxConsumed 由目标View滚动操作消耗的水平像素数 * @param dyConsumed 由目标View滚动操作消耗的垂直像素数 * @param dxUnconsumed 由用户请求但是目标View滚动操作未消耗的水平像素数 * @param dyUnconsumed 由用户请求但是目标View滚动操作未消耗的垂直像素数 */ @Override public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); } /** * 当嵌套滚动的子View准备快速滚动时调用 * * @param coordinatorLayout 父布局CoordinatorLayout * @param child 使用此Behavior的AppBarLayout * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @return 如果Behavior消耗了快速滚动返回true */ @Override public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, float velocityX, float velocityY) { return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY); } /** * 当嵌套滚动的子View快速滚动时调用 * * @param coordinatorLayout 父布局CoordinatorLayout * @param child 使用此Behavior的AppBarLayout * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed 如果嵌套的子View消耗了快速滚动则为true * @return 如果Behavior消耗了快速滚动返回true */ @Override public boolean onNestedFling(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, float velocityX, float velocityY, boolean consumed) { return super.onNestedFling(coordinatorLayout, child, target, velocityX, velocityY, consumed); } /** * 当定制滚动时调用 * * @param coordinatorLayout 父布局CoordinatorLayout * @param abl 使用此Behavior的AppBarLayout * @param target 发起嵌套滚动的目标View(即AppBarLayout下面的ScrollView或RecyclerView) */ @Override public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout abl, View target) { super.onStopNestedScroll(coordinatorLayout, abl, target); } }</code></pre> <p>可以通过打印log来观察AppBarLayout在滚动时 Behavior 中回调方法的调用情况。</p> <p>通过观察可以发现:</p> <ol> <li>上滑时 <ul> <li>当AppBarLayout由展开到收起时,会依次调用onStartNestedScroll()->onNestedScrollAccepted()->onNestedPreScroll()->onStopNestedScroll()</li> <li>当AppBarLayout收起后继续向上滑动时,会依次调用onStartNestedScroll()->onNestedScrollAccepted()->onNestedPreScroll()->onNestedScroll()->onStopNestedScroll()</li> </ul> </li> <li>下滑时 <ul> <li>当AppBarLayout全部展开时(即未到顶部时),会依次调用onStartNestedScroll()->onNestedScrollAccepted()->onNestedPreScroll()->onNestedScroll()->onStopNestedScroll()</li> <li>当AppBarLayout全部展开时(即到顶部时),继续向下滑动屏幕,会依次调用onStartNestedScroll()->onNestedScrollAccepted()->onNestedPreScroll()->onNestedScroll()->onStopNestedScroll()</li> </ul> </li> <li>当有快速滑动时会在onStopNestedScroll()前依次调用onNestedPreFling()->onNestedFling()</li> </ol> <p>所以要修改AppBarLayout的越界行为可以重写onNestedPreScroll()或onNestedScroll(),因为AppBarLayout收起时不会调用onNestedScroll(),所以只能选择重写onNestedPreScroll(),具体原因下面会有说明。</p> <h3>三、重写 Behavior 的相关方法</h3> <p>1.获取越界时需要改变尺寸的View</p> <p>布局时会调用onLayoutChild(),所以在该方法中可获取需要改变尺寸的View,可以使用View的findViewWithTag方法获取指定的View,并初始化属性。</p> <pre> <code class="language-java">public class AppBarLayoutOverScrollViewBehavior extends AppBarLayout.Behavior { private static final String TAG = "overScroll"; private View mTargetView; // 目标View private int mParentHeight; // AppBarLayout的初始高度 private int mTargetViewHeight; // 目标View的高度 @Override public boolean onLayoutChild(CoordinatorLayout parent, AppBarLayout abl, int layoutDirection) { boolean handled = super.onLayoutChild(parent, abl, layoutDirection); // 需要在调用过super.onLayoutChild()方法之后获取 if (mTargetView == null) { mTargetView = parent.findViewWithTag(TAG); if (mTargetView != null) { initial(abl); } } return handled; } private void initial(AppBarLayout abl) { // 必须设置ClipChildren为false,这样目标View在放大时才能超出布局的范围 abl.setClipChildren(false); mParentHeight = abl.getHeight(); mTargetViewHeight = mTargetView.getHeight(); } ... }</code></pre> <p>需要在布局文件或代码中给目标View指定tag,如下:</p> <pre> <code class="language-java"><android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true"> <android.support.design.widget.AppBarLayout android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" android:theme="@style/AppTheme.AppBarOverlay" android:transitionName="picture" app:layout_behavior="com.zly.exifviewer.widget.behavior.AppBarLayoutOverScrollViewBehavior" tools:targetApi="lollipop"> <android.support.design.widget.CollapsingToolbarLayout android:id="@+id/collapsingToolbarLayout" android:layout_width="match_parent" android:layout_height="wrap_content" app:contentScrim="@color/colorPrimary" app:layout_scrollFlags="scroll|enterAlways|enterAlwaysCollapsed" app:statusBarScrim="@color/colorPrimaryDark"> <ImageView android:id="@+id/siv_picture" android:layout_width="match_parent" android:layout_height="200dp" android:fitsSystemWindows="true" android:foreground="@drawable/shape_fg_picture" android:scaleType="centerCrop" android:tag="overScroll" app:layout_collapseMode="parallax" tools:src="@android:drawable/sym_def_app_icon" /> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:contentInsetEnd="64dp" app:layout_collapseMode="pin" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.CollapsingToolbarLayout> </android.support.design.widget.AppBarLayout> <android.support.v4.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_behavior="@string/appbar_scrolling_view_behavior"> ... </android.support.v4.widget.NestedScrollView> </android.support.design.widget.CoordinatorLayout></code></pre> <p>2.下滑处理</p> <p>重写onNestedPreScroll()修改AppBarLayou滑动的顶部后的行为</p> <pre> <code class="language-java">private static final float TARGET_HEIGHT = 500; // 最大滑动距离 private float mTotalDy; // 总滑动的像素数 private float mLastScale; // 最终放大比例 private int mLastBottom; // AppBarLayout的最终Bottom值 @Override public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dx, int dy, int[] consumed) { // 1.mTargetView不为null // 2.是向下滑动,dy<0表示向下滑动 // 3.AppBarLayout已经完全展开,child.getBottom() >= mParentHeight if (mTargetView != null && dy < 0 && child.getBottom() >= mParentHeight) { // 累加垂直方向上滑动的像素数 mTotalDy += -dy; // 不能大于最大滑动距离 mTotalDy = Math.min(mTotalDy, TARGET_HEIGHT); // 计算目标View缩放比例,不能小于1 mLastScale = Math.max(1f, 1f + mTotalDy / TARGET_HEIGHT); // 缩放目标View ViewCompat.setScaleX(mTargetView, mLastScale); ViewCompat.setScaleY(mTargetView, mLastScale); // 计算目标View放大后增加的高度 mLastBottom = mParentHeight + (int) (mTargetViewHeight / 2 * (mLastScale - 1)); // 修改AppBarLayout的高度 child.setBottom(mLastBottom); } else { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed); } }</code></pre> <p>此时可以实现下滑越界时目标View放大,AppBarLayout变高的效果。</p> <p>3.上滑处理</p> <p>下滑时目标View放大,AppBarLayout变高,如果此时用户不松开手指,直接上滑,需要目标View缩小,并且AppBarLayout变高。</p> <p>默认情况下AppBarLayout的滑动是通过修改top和bottom实现的,所以上滑时,AppBarLayout为整体向上移动, 高度不会发生改变,并且AppBarLayout下面的ScrollView也会向上滚动 ;而我们需要的是在AppBarLayout的高度大于原始高度时, 减小AppBarLayout的高度,top不发生改变,并且AppBarLayout下面的ScrollView不会向上滚动 。</p> <p>AppBarLayout上滑时不会调用onNestedScroll(),所以只能在onNestedPreScroll()方法中修改,这也是为什么选择onNestedPreScroll()方法的原因</p> <pre> <code class="language-java">@Override public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dx, int dy, int[] consumed) { if (mTargetView != null && dy < 0 && child.getBottom() >= mParentHeight) { ... } else // 1.mTargetView不为null // 2.是向上滑动,dy>0表示向下滑动 // 3.AppBarLayout尚未恢复到原始高度child.getBottom() > mParentHeight if (mTargetView != null && dy > 0 && child.getBottom() > mParentHeight) { // 累减垂直方向上滑动的像素数 mTotalDy -= dy; // 计算目标View缩放比例,不能小于1 mLastScale = Math.max(1f, 1f + mTotalDy / TARGET_HEIGHT); // 缩放目标View ViewCompat.setScaleX(mTargetView, mLastScale); ViewCompat.setScaleY(mTargetView, mLastScale); // 计算目标View缩小后减少的高度 mLastBottom = mParentHeight + (int) (mTargetViewHeight / 2 * (mLastScale - 1)); // 修改AppBarLayout的高度 child.setBottom(mLastBottom); // 保持target不滑动 target.setScrollY(0); } else { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed); } }</code></pre> <p>与上滑的逻辑基本一直,所以可写为一个方法</p> <pre> <code class="language-java">@Override public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dx, int dy, int[] consumed) { if (mTargetView != null && ((dy < 0 && child.getBottom() >= mParentHeight) || (dy > 0 && child.getBottom() > mParentHeight))) { scale(child, target, dy); } else { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed); } } private void scale(AppBarLayout abl, View target, int dy) { mTotalDy += -dy; mTotalDy = Math.min(mTotalDy, TARGET_HEIGHT); mLastScale = Math.max(1f, 1f + mTotalDy / TARGET_HEIGHT); ViewCompat.setScaleX(mTargetView, mLastScale); ViewCompat.setScaleY(mTargetView, mLastScale); mLastBottom = mParentHeight + (int) (mTargetViewHeight / 2 * (mLastScale - 1)); abl.setBottom(mLastBottom); target.setScrollY(0); }</code></pre> <p>4.还原</p> <p>当AppBarLayout处于越界时,如果用户松开手指,此时应该让目标View和AppBarLayout都还原到原始状态,重写onStopNestedScroll()方法</p> <pre> <code class="language-java">@Override public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout abl, View target) { recovery(abl); super.onStopNestedScroll(coordinatorLayout, abl, target); } private void recovery(final AppBarLayout abl) { if (mTotalDy > 0) { mTotalDy = 0; // 使用属性动画还原 ValueAnimator anim = ValueAnimator.ofFloat(mLastScale, 1f).setDuration(200); anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); ViewCompat.setScaleX(mTargetView, value); ViewCompat.setScaleY(mTargetView, value); abl.setBottom((int) (mLastBottom - (mLastBottom - mParentHeight) * animation.getAnimatedFraction())); } }); anim.start(); } }</code></pre> <p>5.优化</p> <p>由于用户在滑动时有可能触发快速滑动,会导致在AppBarLayout收起后触发还原动画,重新修改AppBarLayout的Bottom,从而显示错误,所以当发生快速滑动时需要禁止还原动画,直接还原到初始状态</p> <pre> <code class="language-java">private boolean isAnimate; //是否有动画 @Override public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes) { // 开始滑动时,启用动画 isAnimate = true; return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes); } @Override public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, float velocityX, float velocityY) { // 如果触发了快速滚动且垂直方向上速度大于100,则禁用动画 if (velocityY > 100) { isAnimate = false; } return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY); } private void recovery(final AppBarLayout abl) { if (mTotalDy > 0) { mTotalDy = 0; if (isAnimate) { ValueAnimator anim = ValueAnimator.ofFloat(mLastScale, 1f).setDuration(200); anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); ViewCompat.setScaleX(mTargetView, value); ViewCompat.setScaleY(mTargetView, value); abl.setBottom((int) (mLastBottom - (mLastBottom - mParentHeight) * animation.getAnimatedFraction())); } }); anim.start(); } else { ViewCompat.setScaleX(mTargetView, 1f); ViewCompat.setScaleY(mTargetView, 1f); abl.setBottom(mParentHeight); } } }</code></pre> <p> </p> <p> </p> <p>来自:http://www.jianshu.com/p/bb3fe452e1f5</p> <p> </p>