笔者在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);
}
}
布局代码:
<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);
}
}
布局代码:
<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就会跟着滑动,从而实现整页的滑动。
<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。