Android嵌套滑动-Behavior方案实战及细节注意
笔者在2013年就收到Android嵌套滑动的UI效果需求,当时都是直接从监听滑动事件分发做起,至今再次收到这种类似的需求,一直以来想更新下之前的实现方式,相对于Behavior封装过的方案而言毕竟不够优雅,现就介绍前后两种方案。
老方案的思路
这种方式是相关api直接使用,其他的封装方式(包括behavoir)都是基于此封装而来,直接重写父类(ViewGroup)的事件分发机制:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent等方法,手动事件分发,当属于逻辑外层滑动时候,进行拦截,满足一定条件之后,再重新分发事件给相关子嵌套的滚动View。这里面代码实现就不展示出来,有点历史,思路在此,不过实现中会有些问题。
例如当重新把move事件分发给子View时,这时子View突然接受到move事件,没有完整的流程经历down事件会导致未初始化而不能响应move事件,就是常见的不能连续滑动的根本原因;其次就是拦截事件不要拦截down事件,会导致某个view点击事件不能响应,滑动都应该只是针对move事件拦截。Behavior方式
在说Behavior之前先简单提下嵌套滑动在5.0之后新增的Api:NestedScrollingParent、NestedScrollingChild以及相应的Helper类,具体介绍不是重点,分别实现这些接口的父View和子View类就能够实现父View对子View嵌套滑动的监听,同时父View和子View之间不一定是直接的上下层关系,子View可以是父view下任意子View,例如NestedScrollView、RecyclerView、CoordinatorLayout(本文重点,下面再讲)都分别实现这两个接口中一个或两个,当然我们可以自定义ViewGroup实现NestedScrollingParent来监听子View的嵌套滑动,贴下代码:
CustomNestedScrollLinearLayout .class
public class CustomNestedScrollLinearLayout extends LinearLayout implements NestedScrollingParent { View mchild, mRecyc, mTitle; public CustomNestedScrollLinearLayout(Context context) { super(context); } public CustomNestedScrollLinearLayout(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); mchild = findViewById(R.id.move); mRecyc = findViewById(R.id.recyclerView); mTitle = findViewById(R.id.title); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); ViewGroup.LayoutParams params = mRecyc.getLayoutParams(); params.height = getMeasuredHeight() - findViewById(R.id.title).getMeasuredHeight(); } @Override public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes) { Log.i("onLayoutChild", "target=" + target.getHeight()); return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; } @Override public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) { int bottom = mchild.getBottom(); int chileHeight = mchild.getHeight(); Log.i("onNestedScroll", "onNestedPreScroll dy=" + dy + " bottom=" + bottom); if (dy > 0 && bottom > 0) { int left = bottom - dy; if (left >= 0) { consumed[1] = dy; } else { consumed[1] =bottom; } mchild.offsetTopAndBottom(-consumed[1]); mTitle.offsetTopAndBottom(-consumed[1]); target.offsetTopAndBottom(-consumed[1]); } } @Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed); Log.i("onNestedScroll", "onNestedScroll dyConsumed=" + dyConsumed + " dyUnconsumed=" + dyUnconsumed); if (dyUnconsumed > 0) { return; } int bottom = mchild.getBottom(); int chileHeight = mchild.getHeight(); if (dyUnconsumed < 0 && bottom < chileHeight) { int left = bottom - dyUnconsumed; int consumed; if (left <= chileHeight) { consumed = dyUnconsumed; } else { consumed = -chileHeight + bottom; } mchild.offsetTopAndBottom(-consumed); mTitle.offsetTopAndBottom(-consumed); target.offsetTopAndBottom(-consumed); } } @Override public void onStopNestedScroll(View child) { Log.i("onNestedScroll", "onStopNestedScroll child=" + child.getClass().getSimpleName()); super.onStopNestedScroll(child); } @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { Log.i("onNestedScroll", "onNestedPreFling target=" + target.getClass().getSimpleName() + " velocityY=" + velocityY); return super.onNestedPreFling(target, velocityX, velocityY); }}
布局代码:
<?xml version="1.0" encoding="utf-8"?><statistics.ymm.com.myapplication.CustomNestedScrollLinearLayout 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:orientation="vertical" tools:context=".MainActivity"> <TextView android:id="@+id/move" android:layout_width="match_parent" android:layout_height="200dp" android:background="@color/colorAccent" android:gravity="center" android:text="Hello World!" /> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="20dp" android:background="@android:color/darker_gray" android:gravity="center" android:text="title" /> <android.support.v7.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorPrimary" app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior" />statistics.ymm.com.myapplication.CustomNestedScrollLinearLayout>
原理本文篇幅不够,不想写,之前都是写关于原理篇,可以百度,今天搞个实战篇,第一次贴代码,拿走就用,不谢。
当然这不是本文重点,确实基础,上文提到了CoordinatorLayout,其中Behavior是就是CoordinatorLayout的静态内部类,对其可以简单理解为在CoordinatorLayout实现NestedScrollingParent2之后,接受到子View的滑动通知之后,把直接通过子View的Behavior来通知回调(注意是直接子View,因为Behavior是CoordinatorLayout.LayoutParams的元素,只能解析直接子View的Behavoir配置),Behavior提供了很多回调,包括了嵌套滑动相关的接口方法。废话不多说,上代码:
public class MyBehavior extends CoordinatorLayout.Behavior { private WeakReference dependentView; public MyBehavior(Context context, AttributeSet attrs) { super(context, attrs); } private View getDependentView() { return dependentView.get(); } @Override public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) { if (dependency != null && dependency.getId() == R.id.move) { dependentView = new WeakReference<>(dependency); return true; } return super.layoutDependsOn(parent, child, dependency); } @Override public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) { child.setTranslationY(dependency.getHeight() + dependency.getTranslationY()); return true; } @Override public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) { return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; } @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); if (dy < 0) { return; } View dependentView = getDependentView(); float newTranslateY = dependentView.getTranslationY() - dy; float minHeaderTranslate = -(dependentView.getHeight()); Log.i("onLayoutChild", "onNestedPreScroll dy=" + dy + "TranslationY='" + dependentView.getTranslationY()); if (newTranslateY >= minHeaderTranslate) { dependentView.setTranslationY(newTranslateY); consumed[1] = dy; } else { if (dependentView.getTranslationY() >= -minHeaderTranslate) { consumed[1] = (int) (dependentView.getTranslationY() - minHeaderTranslate); } dependentView.setTranslationY(minHeaderTranslate); } } @Override public void onNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) { if (dyUnconsumed > 0) { return; } View dependentView = getDependentView(); float currentTranslationY = dependentView.getTranslationY(); float newTranslateY = currentTranslationY - dyUnconsumed; final float maxHeaderTranslate = 0; Log.i("onLayoutChild", "onNestedScroll dyUnconsumed=" + dyUnconsumed + "currentTranslationY="+currentTranslationY); if (newTranslateY <= maxHeaderTranslate) { dependentView.setTranslationY(newTranslateY); } else { dependentView.setTranslationY(maxHeaderTranslate); } super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type); } @Override public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) { return super.onLayoutChild(parent, child, layoutDirection); } @Override public boolean onMeasureChild(CoordinatorLayout parent, View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { return super.onMeasureChild(parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed); }}
布局代码:
<?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" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <TextView android:id="@+id/move" android:layout_width="match_parent" android:layout_height="200dp" android:background="@color/colorAccent" android:gravity="center" android:text="Hello World!" /> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior"> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="20dp" android:background="@android:color/darker_gray" android:gravity="center" android:text="title" /> <android.support.v7.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorPrimary" /> LinearLayout>android.support.design.widget.CoordinatorLayout>
效果图
这里面通过滑动下面的list,会先让红色区域的Hello World!先移动,直到消失之后,list才滑动,中间连贯,不中断,连续滑动,title停在顶层不动。基本满足个人的需求,直接但是如果上面红色header过长话,希望能通过滑动header(Helll World!区域)也能滑动整页,而不是仅仅通过列表滑动来触发的滑动,这时候嵌套滑动就不够满足。上文也提到了Behavior有好多其他回调接口,要想实现Header滑动导致整页滑动,故此我们必须监听Header上面的滑动事件触发,肯定会想到重写Header的事件分发,这会显得麻烦。Behavior就提供了View滑动事件的拦截监听,直接贴代码。
@Override public boolean onInterceptTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) { int dy = (int) ev.getY();// Log.i("chuan", "onInterceptTouchEvent=" + ev.getAction() + "dy=" + dy); View dependView = getDependentView(); if (dependView == null) { return super.onInterceptTouchEvent(parent, child, ev); } switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: downY = (int) ev.getY(); break; case MotionEvent.ACTION_MOVE: lastY = dy; if (Math.abs(lastY - downY) > 1 && dy < (dependView.getMeasuredHeight() + dependView.getTranslationY())) { return true; } break; default: break; } return super.onInterceptTouchEvent(parent, child, ev); } int lastY; @Override public boolean onTouchEvent(CoordinatorLayout parent, View child, MotionEvent ev) { acquireVelocityTracker(ev); final VelocityTracker verTracker = mVelocityTracker; int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_MOVE: int dy = y - lastY;// Log.i("chuan", "onTouchEvent=" + ev.getAction() + "dy=" + dy); if (dy < 0) { moveUp(-dy, new int[2]); } else { movedown(-dy); } lastY = y; break; case MotionEvent.ACTION_CANCEL: //自动 verTracker.computeCurrentVelocity(1000, mMaxVelocity); autoFlingBySpeedIfNedd(-verTracker.getYVelocity()); releaseVelocityTracker(); break; default: break; } return super.onTouchEvent(parent, child, ev); }
重写Behavior中onInterceptTouchEvent等方法,判断手势启动位置,如果Header没有消失,就拦截Move事件,让header移动,header移动之后其dependVIew子View就会跟着滑动,从而实现整页的滑动。
- 细节
-CoordinatorLayout中子View 布局中属性增加MarginBottom或top会导致下面的依赖view之间有重叠覆盖。如上文中的Header若添加margin,会导致其依赖的view之间发生重叠,这个应该是CoordinatorLayout在layout子View时候没有计算上下间距。
2、多个依赖view之间的布局,第3个view要减去第二个view的高度。例如上列中布局可以看到title和list都在一层父布局中,但是如果希望就是都在CoordinatorLayout中该怎么实现,布局如下:
<?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" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <TextView android:id="@+id/move" android:layout_width="match_parent" android:layout_height="200dp" android:background="@color/colorAccent" android:gravity="center" android:text="Hello World!" /> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="20dp" android:background="@android:color/darker_gray" android:gravity="center" android:text="title" app:layout_behavior="statistics.ymm.com.myapplication.MyBehavior" /> <android.support.v7.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorPrimary" app:layout_behavior="statistics.ymm.com.myapplication.Titlebehavior" /> android.support.design.widget.CoordinatorLayout>
这时候就要增加之后布局的依赖关系了,title移动是依赖Header,设置MyBehavior配置,而list就要跟着title移动继续,新增Titlebehavior,让其依赖title,代码如下:
public class Titlebehavior extends CoordinatorLayout.Behavior { public Titlebehavior(Context context, AttributeSet attrs) { super(context, attrs); } private WeakReference dependentView; private View getDependentView() { return dependentView.get(); } @Override public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) { if (dependency != null && dependency.getId() == R.id.title) { dependentView = new WeakReference<>(dependency); return true; } return super.layoutDependsOn(parent, child, dependency); } @Override public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) { child.setTranslationY(dependency.getTranslationY()); return true; } @Override public boolean onLayoutChild(CoordinatorLayout parent, View child, int layoutDirection) { CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) child.getLayoutParams(); if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) { child.layout(0, (int) TypedValue.applyDimension(1, 20, child.getResources().getDisplayMetrics()), parent.getWidth(), (int) (parent.getHeight())); return true; } return super.onLayoutChild(parent, child, layoutDirection); }}
这个时候要注意onLayoutChild对list控件layOut时候要手动减去依赖VIew的高度,也就是title,否则会导致直接覆盖了title。
更多相关文章
- Android(安卓)Monkey工具
- Android的GridView和Gallery结合Demo
- Android通过scroller实现缓慢移动
- Android(安卓)webView嵌套html页面软键盘遮盖页面问题中级解决方
- Android(安卓)自动化测试之Monkey参数介绍及其停止办法
- android 三档开关做法
- 完全理解android事件分发机制
- 点击事件分发机制 关键源码笔记
- Android(安卓)实现item可左右滑动移除的GridView